Skip to content

Commit 59f5f37

Browse files
authored
Merge pull request #384 from easygap/chore/operability-and-docs
운영 통합 헬스 점검 명령 추가 (운영 편의성)
2 parents b3fec47 + 872b9eb commit 59f5f37

6 files changed

Lines changed: 430 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ python main.py --mode dashboard
154154
# 기본 바인드는 http://127.0.0.1:8080 입니다.
155155
# 외부 공개가 필요할 때만 인증/reverse proxy 구성 후 --dashboard-host 0.0.0.0 을 명시하세요.
156156

157+
# 운영 통합 헬스 점검 (전 전략 runtime + current_blockers 한눈에)
158+
python main.py --mode health
159+
# 종료코드 0=OK / 1=ATTENTION / 2=BLOCKED — 모니터링 스크립트에서 분기 가능.
160+
# 같은 점검을 tools/paper_runtime_status.py --health (--json) 으로도 실행할 수 있습니다.
161+
157162
# 휴장일 갱신
158163
python main.py --update-holidays
159164
```

core/operator_health.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""운영자 아침 점검용 통합 헬스 요약.
2+
3+
기존에는 운영자가 시스템 상태를 파악하려면 여러 도구를 따로 돌려야 했다:
4+
- paper_runtime_status.py --all (전략별 runtime state)
5+
- evaluate_and_promote.py --check-only (artifact 동기화/freshness)
6+
- current_blockers.json 직접 확인 (go_live, hard_blockers)
7+
8+
이 모듈은 그 신호들을 하나로 모아 단일 verdict(OK / ATTENTION / BLOCKED)와
9+
사람이 읽는 요약을 만든다. 순수 함수라 외부 상태를 직접 읽지 않고 주입받은
10+
데이터로만 판정하므로 단위 테스트가 쉽다.
11+
12+
verdict 규칙(보수적 — 의심스러우면 주의 이상):
13+
- BLOCKED : frozen 전략 존재, 또는 hard_blocker 존재, 또는 artifact가 stale/손상.
14+
- ATTENTION: degraded/blocked_insufficient_evidence 전략 존재, 또는 manual freeze,
15+
또는 최근 이상치(anomaly) 존재, 또는 go_live=false인데 live_candidate 표기 불일치.
16+
- OK : 위 어느 것에도 안 걸림.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import Any
22+
23+
# verdict 우선순위 (높을수록 심각)
24+
_VERDICT_RANK = {"OK": 0, "ATTENTION": 1, "BLOCKED": 2}
25+
26+
# runtime state별 분류
27+
_BLOCKING_STATES = {"frozen"}
28+
_ATTENTION_STATES = {"degraded", "blocked_insufficient_evidence"}
29+
30+
31+
def _worst(*verdicts: str) -> str:
32+
"""주어진 verdict 중 가장 심각한 것을 반환."""
33+
worst = "OK"
34+
for v in verdicts:
35+
if _VERDICT_RANK.get(v, 0) > _VERDICT_RANK[worst]:
36+
worst = v
37+
return worst
38+
39+
40+
def summarize_runtime_state(state: Any) -> dict[str, Any]:
41+
"""단일 RuntimeState(또는 동등 객체)를 verdict + 요약으로 환원한다.
42+
43+
state는 .state / .strategy / .manual_freeze / .last_anomalies 속성을 가진 객체.
44+
"""
45+
name = getattr(state, "strategy", "?")
46+
s = getattr(state, "state", "unknown")
47+
manual_freeze = bool(getattr(state, "manual_freeze", False))
48+
anomalies = list(getattr(state, "last_anomalies", []) or [])
49+
50+
if s in _BLOCKING_STATES:
51+
verdict = "BLOCKED"
52+
elif s in _ATTENTION_STATES or manual_freeze or anomalies:
53+
verdict = "ATTENTION"
54+
else:
55+
verdict = "OK"
56+
57+
notes = []
58+
if s in _BLOCKING_STATES:
59+
notes.append(f"state={s}")
60+
elif s in _ATTENTION_STATES:
61+
notes.append(f"state={s}")
62+
if manual_freeze:
63+
notes.append("manual_freeze")
64+
if anomalies:
65+
notes.append(f"anomalies={len(anomalies)}")
66+
67+
return {
68+
"strategy": name,
69+
"state": s,
70+
"verdict": verdict,
71+
"manual_freeze": manual_freeze,
72+
"anomaly_count": len(anomalies),
73+
"notes": notes,
74+
}
75+
76+
77+
def summarize_blockers(blockers: dict[str, Any] | None) -> dict[str, Any]:
78+
"""current_blockers.json 페이로드를 verdict + 요약으로 환원한다."""
79+
if not blockers:
80+
return {
81+
"verdict": "ATTENTION",
82+
"go_live": False,
83+
"hard_blocker_count": 0,
84+
"notes": ["current_blockers 없음/로드 실패"],
85+
"freshness_stale": True,
86+
}
87+
88+
hard = list(blockers.get("hard_blockers") or [])
89+
go_live = bool(blockers.get("go_live", False))
90+
live_candidates = list(blockers.get("live_candidates") or [])
91+
freshness = blockers.get("promotion_artifact_freshness") or {}
92+
# freshness가 dict면 stale 여부를 본다(없으면 보수적으로 미상=stale 취급하지 않음).
93+
stale = False
94+
if isinstance(freshness, dict):
95+
stale = bool(freshness.get("stale", False)) or freshness.get("status") in ("stale", "expired")
96+
97+
notes = []
98+
verdict = "OK"
99+
if hard:
100+
verdict = "BLOCKED"
101+
notes.append(f"hard_blockers={len(hard)}")
102+
if stale:
103+
verdict = _worst(verdict, "BLOCKED")
104+
notes.append("artifact_stale")
105+
# go_live=false인데 live_candidates가 비어있지 않으면 표기 불일치(주의).
106+
if not go_live and live_candidates:
107+
verdict = _worst(verdict, "ATTENTION")
108+
notes.append("go_live=false_but_live_candidates_present")
109+
110+
return {
111+
"verdict": verdict,
112+
"go_live": go_live,
113+
"live_candidates": live_candidates,
114+
"hard_blocker_count": len(hard),
115+
"hard_blockers": hard,
116+
"freshness_stale": stale,
117+
"notes": notes,
118+
}
119+
120+
121+
def build_operator_health(
122+
runtime_states: list[Any],
123+
blockers: dict[str, Any] | None,
124+
) -> dict[str, Any]:
125+
"""전략별 runtime state + current_blockers를 하나의 헬스 요약으로 합친다.
126+
127+
반환:
128+
{
129+
"verdict": "OK" | "ATTENTION" | "BLOCKED",
130+
"strategy_count": N,
131+
"strategies": [summarize_runtime_state(...), ...],
132+
"blockers": summarize_blockers(...),
133+
"headline": 사람이 읽는 한 줄 요약,
134+
"attention_items": [...], # 운영자가 봐야 할 항목들
135+
}
136+
"""
137+
strat_summaries = [summarize_runtime_state(s) for s in runtime_states]
138+
blocker_summary = summarize_blockers(blockers)
139+
140+
verdict = "OK"
141+
for s in strat_summaries:
142+
verdict = _worst(verdict, s["verdict"])
143+
verdict = _worst(verdict, blocker_summary["verdict"])
144+
145+
attention_items: list[str] = []
146+
for s in strat_summaries:
147+
if s["verdict"] != "OK":
148+
attention_items.append(f"{s['strategy']}: {', '.join(s['notes']) or s['state']}")
149+
if blocker_summary["notes"]:
150+
attention_items.append("blockers: " + ", ".join(blocker_summary["notes"]))
151+
152+
n = len(strat_summaries)
153+
n_ok = sum(1 for s in strat_summaries if s["verdict"] == "OK")
154+
if verdict == "OK":
155+
headline = f"전체 정상 — 전략 {n}개 모두 OK, go_live={blocker_summary['go_live']}"
156+
elif verdict == "ATTENTION":
157+
headline = f"주의 필요 — 전략 {n}개 중 {n_ok}개 OK, 확인 항목 {len(attention_items)}건"
158+
else:
159+
headline = f"차단 상태 — 운영 개입 필요, 확인 항목 {len(attention_items)}건"
160+
161+
return {
162+
"verdict": verdict,
163+
"strategy_count": n,
164+
"strategies": strat_summaries,
165+
"blockers": blocker_summary,
166+
"headline": headline,
167+
"attention_items": attention_items,
168+
}

docs/PAPER_TO_LIVE_RUNBOOK.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@
2727
```bash
2828
# 항상 프로젝트 venv 사용 (bare python은 의존성 없음)
2929
.venv\Scripts\python.exe -m pytest tests/ -q # 전체 그린 확인
30+
.venv\Scripts\python.exe tools/paper_runtime_status.py --health # 통합 헬스 한눈에(아침 점검)
3031
.venv\Scripts\python.exe tools/evaluate_and_promote.py --check-only # 운영 artifact 동기화/freshness
3132
```
33+
- `--health`는 전 전략 runtime state + `current_blockers`를 모아 단일 verdict(OK/ATTENTION/BLOCKED)와
34+
종료코드(0/1/2)를 준다. 모니터링 스크립트에서 종료코드로 분기하거나 `--json`으로 파싱할 수 있다.
3235
- `--check-only`가 FAIL(stale)이면 §3의 `--canonical` 재실행 필요.
3336
- 테스트는 임시 DB로 격리되어 **운영 `data/quant_trader.db`를 건드리지 않는다**
3437
(이전 "DB 복구(restore)" 반복의 근본 원인이었음 — `tests/conftest.py`로 차단).
@@ -86,7 +89,13 @@ MDD/PF/turnover 게이트 통과 + **정직한(생존자 통제) 백테스트
8689

8790
1. **시점 유니버스 재검증**(#5): pykrx 동작 환경에서 `--canonical``survivorship_controlled=true`
8891
target_weight 진짜 엣지 측정. 헤드라인 대비 얼마나 남는지 판정.
89-
2. **진짜 OOS holdout**(#6): 함수는 구현됨 — `research_candidate_sweep.evaluate_oos_holdout()`
90-
(train 구간 rank_score로만 변형 선택 → untouched test 구간 성과/degradation 보고).
91-
남은 일: `run_candidate_sweep``--oos-holdout-split` 옵션으로 연결 + 정직한 유니버스 환경에서 실행.
92+
2. **진짜 OOS holdout**(#6): 구현·연결 완료. sweep 실행 시 `--oos-holdout-split YYYY-MM-DD`
93+
주면 그 이전(train)으로만 변형을 고르고 untouched 그 이후(test) 성과/degradation을
94+
artifact의 `oos_holdout`에 보고한다. 예:
95+
```bash
96+
.venv\Scripts\python.exe tools/research_candidate_sweep.py \
97+
--candidate-family target_weight_drawdown_guard --top-n 200 --oos-holdout-split 2025-01-01
98+
```
99+
`oos_holdout.holdout_passes=false` 또는 `sharpe_degradation`이 크면 선택 과적합 신호.
100+
남은 일: 정직한(생존자 통제) 유니버스 환경에서 실제 실행해 degradation 수치 확보.
92101
3. **deflated Sharpe 게이트화**: 현재 report-only인 DSR/생존자 경고를 승격 게이트에 연결 검토.

main.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,53 @@ def _get_strategy(name: str):
13601360
return create_strategy(name)
13611361

13621362

1363+
def run_health_check() -> int:
1364+
"""운영 통합 헬스 점검 — 전 전략 runtime state + current_blockers를 한 번에 요약.
1365+
1366+
tools/paper_runtime_status.py --health 와 동일한 로직. main.py 진입점에서도
1367+
바로 쓸 수 있게 노출한다. 반환 코드: 0=OK, 1=ATTENTION, 2=BLOCKED.
1368+
"""
1369+
import json
1370+
from pathlib import Path
1371+
from database.models import init_database
1372+
from core.paper_runtime import get_paper_runtime_state
1373+
from core.strategy_universe import get_paper_strategy_names
1374+
from core.operator_health import build_operator_health
1375+
1376+
init_database()
1377+
strategies = sorted(get_paper_strategy_names() or [])
1378+
states = []
1379+
for strategy in strategies:
1380+
try:
1381+
states.append(get_paper_runtime_state(strategy))
1382+
except Exception as exc:
1383+
logger.warning("{} 상태 조회 실패: {}", strategy, exc)
1384+
1385+
blockers = None
1386+
blockers_path = Path(__file__).resolve().parent / "reports" / "current_blockers.json"
1387+
if blockers_path.exists():
1388+
try:
1389+
blockers = json.loads(blockers_path.read_text(encoding="utf-8"))
1390+
except Exception:
1391+
blockers = None
1392+
1393+
health = build_operator_health(states, blockers)
1394+
icon = {"OK": "✅", "ATTENTION": "⚠️", "BLOCKED": "⛔"}.get(health["verdict"], "❓")
1395+
logger.info("{} 운영 헬스: {} — {}", icon, health["verdict"], health["headline"])
1396+
b = health["blockers"]
1397+
logger.info(
1398+
"go_live={} | hard_blockers={} | artifact_stale={}",
1399+
b["go_live"], b["hard_blocker_count"], b["freshness_stale"],
1400+
)
1401+
for s in health["strategies"]:
1402+
extra = f" ({', '.join(s['notes'])})" if s["notes"] else ""
1403+
logger.info(" - {}: {}{}", s["strategy"], s["state"], extra)
1404+
for item in health["attention_items"]:
1405+
logger.info(" 확인: {}", item)
1406+
1407+
return {"OK": 0, "ATTENTION": 1, "BLOCKED": 2}.get(health["verdict"], 1)
1408+
1409+
13631410
def main():
13641411
"""메인 진입점"""
13651412
parser = argparse.ArgumentParser(
@@ -1438,8 +1485,9 @@ def main():
14381485
"check_correlation",
14391486
"check_ensemble_correlation",
14401487
"rebalance",
1488+
"health",
14411489
],
1442-
help="실행 모드. backtest_momentum_top: 모멘텀 상위 동일비중 멀티종목. portfolio_backtest: 멀티종목 포트폴리오 백테스트. paper: 워치리스트 1회. schedule: 모의 스케줄 무한 루프(상시 서버). rebalance: 바스켓 리밸런싱.",
1490+
help="실행 모드. backtest_momentum_top: 모멘텀 상위 동일비중 멀티종목. portfolio_backtest: 멀티종목 포트폴리오 백테스트. paper: 워치리스트 1회. schedule: 모의 스케줄 무한 루프(상시 서버). rebalance: 바스켓 리밸런싱. health: 운영 통합 헬스 점검(전 전략 runtime + blockers).",
14431491
)
14441492
from strategies import get_strategy_names
14451493
parser.add_argument(
@@ -1611,6 +1659,8 @@ def main():
16111659
run_check_ensemble_correlation(args)
16121660
elif args.mode == "rebalance":
16131661
run_rebalance(args)
1662+
elif args.mode == "health":
1663+
raise SystemExit(run_health_check())
16141664
else:
16151665
logger.error("알 수 없는 모드: {}", args.mode)
16161666
except KeyboardInterrupt:

0 commit comments

Comments
 (0)