Skip to content

Commit 142972c

Browse files
authored
Merge pull request #431 from easygap/feat/deployment-health
feat: 집계 배치율 감시 — 설계 대비 미달을 헬스로 표면화
2 parents 8902435 + 848c99b commit 142972c

4 files changed

Lines changed: 143 additions & 1 deletion

File tree

core/operator_health.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,51 @@ def summarize_blockers(blockers: dict[str, Any] | None) -> dict[str, Any]:
126126
}
127127

128128

129+
def summarize_deployment(
130+
deployment_ratio: float | None,
131+
design_fraction: float | None,
132+
*,
133+
tolerance: float = 0.05,
134+
) -> dict[str, Any]:
135+
"""집계 배치율(총자산 중 실제 주식비중)이 설계 대비 크게 미달인지 판정(순수 함수).
136+
137+
한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P1-5)의 배경: 종목별 드리프트
138+
트리거는 '집계 배치율' 이탈(예: 실효 61% vs 설계 80%)을 영영 못 본다. 여기서 그 이탈을
139+
운영자 헬스로 표면화한다. 실제가 설계보다 tolerance(기본 5%p) 초과로 낮으면 ATTENTION.
140+
(초과 배치는 리밸런서가 자연 교정하므로 '미달'만 감시한다.)
141+
142+
반환: {verdict(OK|ATTENTION), note(str|None), deployment_ratio, design_fraction, shortfall}
143+
"""
144+
if deployment_ratio is None or design_fraction is None:
145+
return {
146+
"verdict": "OK", "note": None,
147+
"deployment_ratio": deployment_ratio, "design_fraction": design_fraction,
148+
"shortfall": None,
149+
}
150+
shortfall = float(design_fraction) - float(deployment_ratio)
151+
verdict, note = "OK", None
152+
if shortfall > tolerance:
153+
verdict = "ATTENTION"
154+
note = (
155+
f"주식 배치율 {deployment_ratio:.0%} < 설계 {design_fraction:.0%} "
156+
f"({-shortfall * 100:.1f}%p) — 미체결 슬롯/자본 점검"
157+
)
158+
return {
159+
"verdict": verdict, "note": note,
160+
"deployment_ratio": float(deployment_ratio), "design_fraction": float(design_fraction),
161+
"shortfall": shortfall,
162+
}
163+
164+
129165
def summarize_basket_operation(
130166
enabled_baskets: list[str],
131167
last_snapshot_date: Any,
132168
position_count: int,
133169
today: Any,
134170
max_stale_calendar_days: int = 4,
171+
deployment_ratio: float | None = None,
172+
design_fraction: float | None = None,
173+
deployment_tolerance: float = 0.05,
135174
) -> dict[str, Any]:
136175
"""바스켓 paper 운영(트랙레코드 축적) 상태를 verdict + 요약으로 환원한다.
137176
@@ -158,6 +197,8 @@ def _as_date(v: Any):
158197
"last_snapshot_date": None,
159198
"position_count": int(position_count or 0),
160199
"stale_days": None,
200+
"deployment_ratio": None,
201+
"design_fraction": None,
161202
"notes": ["enabled 바스켓 없음(운영 안 함)"],
162203
}
163204

@@ -176,12 +217,23 @@ def _as_date(v: Any):
176217
"일일 사이클 중단 의심"
177218
)
178219

220+
# 집계 배치율 미달 감시 — 종목별 드리프트가 못 보는 설계 대비 이탈을 표면화.
221+
dep = summarize_deployment(
222+
deployment_ratio, design_fraction, tolerance=deployment_tolerance,
223+
)
224+
if dep["verdict"] == "ATTENTION":
225+
verdict = "ATTENTION"
226+
if dep["note"]:
227+
notes.append(dep["note"])
228+
179229
return {
180230
"verdict": verdict,
181231
"enabled_baskets": list(enabled_baskets),
182232
"last_snapshot_date": _as_date(last_snapshot_date) if last_snapshot_date is not None else None,
183233
"position_count": int(position_count or 0),
184234
"stale_days": stale_days,
235+
"deployment_ratio": dep["deployment_ratio"],
236+
"design_fraction": dep["design_fraction"],
185237
"notes": notes,
186238
}
187239

docs/PAPER_MONTH1_REVIEW_AND_PLAN.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,13 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를
168168
> 룩백 14일로 확대(명절 연휴 클러스터 커버), live 동기화 실패 시 CYCLE_ERROR 기록,
169169
> run_rebalance 배선 통합 테스트 보강(스킵/critical 분기).
170170
> - ⬜ P0-2 SMTP 재발급 — 오너 액션
171-
> - ⬜ P1~P3 — 대기
171+
> -**P1-5 집계 배치율 감시**`core/operator_health.py`에 순수 `summarize_deployment`
172+
> 추가, `summarize_basket_operation`이 설계 대비 -5%p 초과 미달 시 ATTENTION 승격.
173+
> `--mode health`가 최신 스냅샷으로 배치율 계산(네트워크 불필요) → 종목별 드리프트가
174+
> 못 보던 집계 이탈을 표면화. 실CLI 스모크로 59%<80% ATTENTION 확인(스모크가 `config`
175+
> 미정의 NameError로 바스켓 헬스가 통째로 죽던 것도 적발·수정). 적대적 리뷰 반영:
176+
> 배치율 계산을 자체 try로 격리(bad config가 결측 감지를 삼키지 않게), 경계 테스트 보강.
177+
> - ⬜ P1-4/6/7 (귀속 자동화·스냅샷 시점 정합·주간 리포트), P2~P3 — 대기
172178
173179
### P0 — 자본 결정과 같은 주에
174180

main.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,11 +1631,30 @@ def run_health_check() -> int:
16311631

16321632
from core.basket_rebalancer import rebalance_live_strategy_id
16331633

1634+
config = Config.get()
16341635
enabled_baskets = BasketRebalancer.get_enabled_baskets()
1636+
baskets_cfg = BasketRebalancer._load_baskets_config()
1637+
min_cash_ratio = (
1638+
config.risk_params.get("diversification", {}).get("min_cash_ratio", 0.20)
1639+
)
1640+
1641+
def _design_fraction(cfg: dict) -> float:
1642+
"""BasketRebalancer._stock_fraction과 동일 규칙(인스턴스 없이 계산)."""
1643+
max_stock = 1.0 - min_cash_ratio
1644+
tsw = cfg.get("target_stock_weight")
1645+
if tsw is None:
1646+
return max_stock
1647+
return max(0.0, min(float(tsw), max_stock))
1648+
16351649
# 바스켓별 전용 계정 키(basket_rebalance:<name>) 기준으로 조회한다.
16361650
# 복수 바스켓이면 '가장 오래된 최신 스냅샷'을 기준으로(가장 뒤처진 사이클 감시).
16371651
last_dates = []
16381652
position_count = 0
1653+
# 집계 배치율 미달 감시: 최신 스냅샷으로 실제 주식비중을 계산(네트워크 불필요),
1654+
# 설계 대비 가장 크게 미달인 바스켓을 대표로 넘긴다(가장 뒤처진 배치 감시).
1655+
worst_shortfall = None
1656+
worst_dep_ratio = None
1657+
worst_design = None
16391658
session = get_session()
16401659
try:
16411660
for name in enabled_baskets:
@@ -1648,6 +1667,20 @@ def run_health_check() -> int:
16481667
)
16491668
last_dates.append(snap.date if snap else None)
16501669
position_count += len(get_all_positions(account_key=key) or [])
1670+
# 배치율은 부가 신호 — 계산 실패(예: baskets.yaml에 float 불가한
1671+
# target_stock_weight 오타)가 핵심 신호인 결측/staleness 감지를
1672+
# 통째로 삼키지 않도록 자체 try로 격리한다.
1673+
try:
1674+
if snap and snap.total_value and snap.total_value > 0:
1675+
dep_ratio = max(0.0, (snap.total_value - (snap.cash or 0)) / snap.total_value)
1676+
design = _design_fraction(baskets_cfg.get(name) or {})
1677+
shortfall = design - dep_ratio
1678+
if worst_shortfall is None or shortfall > worst_shortfall:
1679+
worst_shortfall = shortfall
1680+
worst_dep_ratio = dep_ratio
1681+
worst_design = design
1682+
except Exception as dep_exc:
1683+
logger.debug("바스켓 '{}' 배치율 계산 생략: {}", name, dep_exc)
16511684
finally:
16521685
session.close()
16531686
oldest_last = (
@@ -1659,6 +1692,8 @@ def run_health_check() -> int:
16591692
"last_snapshot_date": oldest_last,
16601693
"position_count": position_count,
16611694
"today": date.today(),
1695+
"deployment_ratio": worst_dep_ratio,
1696+
"design_fraction": worst_design,
16621697
}
16631698
except Exception as exc:
16641699
logger.warning("바스켓 운영 상태 조회 실패: {}", exc)

tests/test_operator_health.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,55 @@ def test_datetime_inputs_are_normalized(self):
197197
)
198198
assert out["verdict"] == "OK"
199199

200+
def test_underdeployment_escalates_to_attention(self):
201+
# 신선한 스냅샷이라도 배치율 미달(59% vs 설계 80%, -21%p > 5%p)이면 ATTENTION
202+
out = self._summ(deployment_ratio=0.59, design_fraction=0.80)
203+
assert out["verdict"] == "ATTENTION"
204+
assert any("배치율" in n for n in out["notes"])
205+
assert out["deployment_ratio"] == 0.59
206+
207+
def test_deployment_within_tolerance_stays_ok(self):
208+
out = self._summ(deployment_ratio=0.77, design_fraction=0.80) # -3%p ≤ 5%p
209+
assert out["verdict"] == "OK"
210+
211+
def test_deployment_none_is_ok(self):
212+
out = self._summ(deployment_ratio=None, design_fraction=None)
213+
assert out["verdict"] == "OK"
214+
215+
216+
class TestSummarizeDeployment:
217+
"""집계 배치율 미달 판정(순수 함수)."""
218+
219+
def _f(self, ratio, design, **kw):
220+
from core.operator_health import summarize_deployment
221+
return summarize_deployment(ratio, design, **kw)
222+
223+
def test_shortfall_beyond_tolerance_is_attention(self):
224+
out = self._f(0.61, 0.80)
225+
assert out["verdict"] == "ATTENTION"
226+
assert "61%" in out["note"] and "80%" in out["note"]
227+
228+
def test_within_tolerance_ok(self):
229+
assert self._f(0.76, 0.80)["verdict"] == "OK"
230+
231+
def test_overdeployment_is_ok(self):
232+
# 초과 배치는 리밸런서가 자연 교정 — 감시 대상 아님
233+
assert self._f(0.90, 0.80)["verdict"] == "OK"
234+
235+
def test_none_inputs_ok(self):
236+
assert self._f(None, 0.80)["verdict"] == "OK"
237+
assert self._f(0.6, None)["verdict"] == "OK"
238+
239+
def test_custom_tolerance(self):
240+
assert self._f(0.75, 0.80, tolerance=0.10)["verdict"] == "OK" # -5%p ≤ 10%p
241+
assert self._f(0.68, 0.80, tolerance=0.10)["verdict"] == "ATTENTION" # -12%p
242+
243+
def test_exact_boundary_is_ok_strict_comparison(self):
244+
# shortfall == tolerance 이면 OK(엄격 '>' 고정). tolerance=0으로 부동소수 오차 없이
245+
# 경계를 못박는다 — '>'를 '>='로 바꾸면 완벽 배치도 ATTENTION이 되어 이 테스트가 잡는다.
246+
assert self._f(0.80, 0.80, tolerance=0.0)["verdict"] == "OK"
247+
assert self._f(0.79, 0.80, tolerance=0.0)["verdict"] == "ATTENTION"
248+
200249

201250
class TestBuildOperatorHealthWithBasket:
202251
def test_basket_attention_escalates_overall_verdict(self):

0 commit comments

Comments
 (0)