From f05724bcc834f98bf4597502b4c9f6d22bd7a859 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 7 Jul 2026 13:40:26 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=94=EC=8A=A4=EC=BC=93=EB=B3=84=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=EC=9C=A8=20=ED=97=88=EC=9A=A9=20=EC=98=A4?= =?UTF-8?q?=EC=B0=A8=20=E2=80=94=20=EA=B4=80=EC=B0=B0=EC=9A=A9=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=EC=9D=98=20=EC=98=81=EA=B5=AC=20ATTENTION=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4차 점검(운영 로그·DB·이벤트 실측)에서 확인: 로그의 모든 에러가 기수정 버그의 흔적(직렬화 500 x17, UNIQUE 충돌 x1)이고 오늘 에러 0건 — 수정들이 운영에서 실증됐다. 남은 문제는 하나: 관찰용으로 강등한 kr_diversified_hold의 배치율 미달(-21%p, 종결된 자본 결정의 잔상)이 상시 ATTENTION으로 남아 경보 피로를 만들고, 최악치 하나만 고르는 로직 때문에 kr_pocket의 배치율 감시를 가렸다. 수정: - baskets.yaml에 바스켓별 monitoring.deployment_tolerance 도입 - kr_diversified_hold: 1.0 (관찰용 — 감시 사실상 해제, 근거 주석) - kr_pocket: 0.10 (1주 절사 구조상 -7.3%p 상시 — 이걸 넘으면 진짜 이상) - run_health_check: 원시 미달폭 대신 (미달폭 - 바스켓 허용)의 초과분이 가장 큰 바스켓을 선택 — 완화된 트랙이 감시 대상을 가리지 않게 - summarize_basket_operation에 허용 오차 전달(기존 파라미터 활용) 실CLI 확인: 바스켓 라인 깨끗, 확인 항목 3→2건(잔여 2건은 연구 전략의 정상 상태). 전체 스위트 1652 통과. --- config/baskets.yaml | 10 ++++++++++ main.py | 18 ++++++++++++++---- tests/test_basket_rebalancer.py | 9 +++++++++ tests/test_operator_health.py | 12 ++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/config/baskets.yaml b/config/baskets.yaml index 9ada31c9..fc90e65a 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -40,6 +40,11 @@ baskets: kr_diversified_hold: name: "분산 대형주 buy&hold (저회전)" enabled: true + # 관찰용 강등(docs/POCKET_TRACK_PLAN.md §6): 소액 전환으로 자본 증액 결정이 종결돼 + # 배치율 미달(-21%p, 하이닉스 슬롯+절사)은 조치 대상이 아니다 — 상시 ATTENTION은 + # 경보 피로만 남기고 다른 바스켓의 배치율 이탈을 가린다. 감시 사실상 해제(1.0). + monitoring: + deployment_tolerance: 1.0 rebalance: trigger: drift # 분기 점검을 권장하나 drift로 큰 이탈만 교정 drift_threshold: 0.08 # 8%p 이상 이탈 시에만 리밸런싱(잦은 매매 억제) @@ -95,6 +100,11 @@ baskets: enabled: true initial_capital: 300000 # 시작 자본 30만원 (paper — live 전환 시 실자본과 일치) target_stock_weight: 0.5 # 주식 50% / 현금 50% — 낙폭 절반이 목적 + # 소액 구조적 절사: ETF 1주(약 12.8만) 단위라 30만 시점 배치율이 -7.3%p 상시 미달 + # (docs/POCKET_TRACK_PLAN.md §3 — 적립 누적 시 자연 수렴). 기본 허용 5%p면 매일 + # 무의미한 ATTENTION이라 10%p로 완화 — 이걸 넘으면 절사가 아니라 진짜 이상이다. + monitoring: + deployment_tolerance: 0.10 rebalance: trigger: drift drift_threshold: 0.08 diff --git a/main.py b/main.py index f0cb4a07..5aa21d9d 100644 --- a/main.py +++ b/main.py @@ -1710,6 +1710,7 @@ def _design_fraction(cfg: dict) -> float: worst_shortfall = None worst_dep_ratio = None worst_design = None + worst_tolerance = 0.05 session = get_session() try: for name in enabled_baskets: @@ -1725,15 +1726,23 @@ def _design_fraction(cfg: dict) -> float: # 배치율은 부가 신호 — 계산 실패(예: baskets.yaml에 float 불가한 # target_stock_weight 오타)가 핵심 신호인 결측/staleness 감지를 # 통째로 삼키지 않도록 자체 try로 격리한다. + # 바스켓별 허용 오차(monitoring.deployment_tolerance, 기본 5%p)를 + # 반영해 '허용 초과분'이 가장 큰 바스켓을 고른다 — 원시 미달폭 + # 최대로 고르면 관찰용 트랙(허용 완화)이 진짜 감시 대상을 가린다. try: if snap and snap.total_value and snap.total_value > 0: + cfg_b = baskets_cfg.get(name) or {} dep_ratio = max(0.0, (snap.total_value - (snap.cash or 0)) / snap.total_value) - design = _design_fraction(baskets_cfg.get(name) or {}) - shortfall = design - dep_ratio - if worst_shortfall is None or shortfall > worst_shortfall: - worst_shortfall = shortfall + design = _design_fraction(cfg_b) + tol_b = float( + (cfg_b.get("monitoring") or {}).get("deployment_tolerance", 0.05) + ) + violation = (design - dep_ratio) - tol_b + if worst_shortfall is None or violation > worst_shortfall: + worst_shortfall = violation worst_dep_ratio = dep_ratio worst_design = design + worst_tolerance = tol_b except Exception as dep_exc: logger.debug("바스켓 '{}' 배치율 계산 생략: {}", name, dep_exc) finally: @@ -1749,6 +1758,7 @@ def _design_fraction(cfg: dict) -> float: "today": date.today(), "deployment_ratio": worst_dep_ratio, "design_fraction": worst_design, + "deployment_tolerance": worst_tolerance, } except Exception as exc: logger.warning("바스켓 운영 상태 조회 실패: {}", exc) diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index af2c6a35..78fa5149 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -361,6 +361,15 @@ def test_pocket_basket_small_capital_invariants(self): assert float(rb["max_turnover_ratio"]) >= tsw, "회전 한도 < 슬리브면 초기 매수 불가" # ETF 단일 구성(1주 = 200종목 분산) assert list(b["holdings"].keys()) == ["069500"] + # 소액 구조적 절사(1주 단위 -7.3%p) 허용 — 기본 5%p면 매일 무의미 ATTENTION + assert float(b["monitoring"]["deployment_tolerance"]) == 0.10 + + def test_observation_track_deployment_alarm_disabled(self): + """관찰용 강등(kr_diversified_hold): 종결된 자본 결정의 잔상인 배치율 미달이 + 상시 ATTENTION으로 남아 다른 바스켓 감시를 가리지 않도록 허용 오차 해제.""" + baskets = self._load() + b = baskets["kr_diversified_hold"] + assert float(b["monitoring"]["deployment_tolerance"]) >= 1.0 def test_all_basket_symbols_are_6digit_kr_codes(self): baskets = self._load() diff --git a/tests/test_operator_health.py b/tests/test_operator_health.py index 65f28d27..450ba2d5 100644 --- a/tests/test_operator_health.py +++ b/tests/test_operator_health.py @@ -212,6 +212,18 @@ def test_deployment_none_is_ok(self): out = self._summ(deployment_ratio=None, design_fraction=None) assert out["verdict"] == "OK" + def test_per_basket_tolerance_relaxes_attention(self): + # 관찰용 강등 바스켓: 허용 오차 완화 시 큰 미달도 OK (경보 피로 방지) + out = self._summ( + deployment_ratio=0.59, design_fraction=0.80, deployment_tolerance=1.0, + ) + assert out["verdict"] == "OK" + # 소액 구조적 절사(10%p 허용): -7.3%p는 OK, -12%p는 ATTENTION + ok = self._summ(deployment_ratio=0.427, design_fraction=0.50, deployment_tolerance=0.10) + assert ok["verdict"] == "OK" + bad = self._summ(deployment_ratio=0.38, design_fraction=0.50, deployment_tolerance=0.10) + assert bad["verdict"] == "ATTENTION" + class TestSummarizeDeployment: """집계 배치율 미달 판정(순수 함수)."""