From 16fc64056212d4010fd10f10accfc96315b9ef61 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 7 Jul 2026 16:04:14 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=94=EC=8A=A4=EC=BC=93=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=9E=90=EB=B3=B8=20=EC=A4=91=EC=95=99=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D=20=E2=80=94=20=EC=A3=BC=EB=AC=B8=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20=EA=B0=80=EB=93=9C=20=EB=B6=84=EB=AA=A8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6차 점검: 8/1 입금 흐름을 격리 DB에서 실코드 E2E로 실행하다 발견. 주문 리스크 가드(_drawdown_pre_order_check — MDD·일일 손실 한도로 신규 BUY 차단)가 PortfolioManager를 account_key만으로 열어 전역 자본(10M)으로 폴백 → 30만 kr_pocket의 총평가가 10.4M로 오산돼 손실 한도가 사실상 무력화되던 결함. #426이 평가기의 바스켓별 자본은 고쳤지만 이 폴백은 남아 있었다. paper에선 조용하지만 live 전환 시 리스크 가드 구멍이 된다. 수정: PortfolioManager.__init__이 basket_rebalance: 계정 키면 baskets.yaml의 바스켓별 initial_capital을 중앙 해석(지연 임포트로 순환 참조 회피, 해석 실패 시 기존 전역 폴백 유지) — account_key만으로 이 계정을 여는 현재·미래 호출부(주문 가드, live 동기화 등)를 일괄 커버. 명시 인자·비바스켓 키는 기존 동작 그대로(테스트 고정). 검증: 격리 DB 실측 — 가드 경로 PM 자본 300,000·총평가 519,981(이전 10.4M) 정상화. E2E(입금 중화·40만 보류·52만 흡수)도 실코드 PASS. 회귀 4케이스 + 전체 스위트 1660 통과. --- core/portfolio_manager.py | 28 ++++++++++++++++++++--- tests/test_cash_flows.py | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/core/portfolio_manager.py b/core/portfolio_manager.py index f4c4a2bf..ebe0de05 100644 --- a/core/portfolio_manager.py +++ b/core/portfolio_manager.py @@ -54,9 +54,31 @@ def __init__(self, config: Config = None, account_key: str = "", initial_capital if initial_capital is not None: self.initial_capital = float(initial_capital) else: - self.initial_capital = self.config.risk_params.get( - "position_sizing", {} - ).get("initial_capital", 10000000) + # 바스켓 계정(basket_rebalance:)은 baskets.yaml의 바스켓별 자본이 + # 진실이다 — account_key만으로 이 계정을 여는 모든 경로(주문 리스크 가드 + # 등)가 전역 자본(10M)으로 폴백하면 분모가 틀어져, 소액 바스켓(예: 30만 + # kr_pocket)에서 MDD·일일 손실 한도가 사실상 무력화된다(#426이 평가기는 + # 고쳤지만 이 폴백은 남아 있었다 — 6차 점검 E2E에서 실측). 중앙 해석으로 + # 현재·미래 호출부를 일괄 커버한다. 해석 실패 시 기존 폴백 유지. + resolved = None + if (self.account_key or "").startswith("basket_rebalance:"): + try: + from core.basket_rebalancer import BasketRebalancer + + _basket = self.account_key.split(":", 1)[1] + _cap = ( + BasketRebalancer._load_baskets_config().get(_basket) or {} + ).get("initial_capital") + resolved = float(_cap) if _cap is not None else None + except Exception as exc: + logger.debug("바스켓 자본 해석 실패(전역 폴백): {}", exc) + self.initial_capital = ( + resolved + if resolved is not None + else self.config.risk_params.get( + "position_sizing", {} + ).get("initial_capital", 10000000) + ) self._is_live = self.config.trading.get("mode", "paper") == "live" # Peak value 복구: DB 스냅샷에서 이전 세션의 peak을 가져와 MDD 연속성 유지 diff --git a/tests/test_cash_flows.py b/tests/test_cash_flows.py index 8bd47a84..c1f2ced6 100644 --- a/tests/test_cash_flows.py +++ b/tests/test_cash_flows.py @@ -101,6 +101,54 @@ def _pm(monkeypatch, account, initial=300_000, cash_delta=0.0, deposits=0.0, return pm +class TestBasketCapitalResolution: + """바스켓 계정 키만으로 PM을 열어도 baskets.yaml 자본이 분모여야 한다. + + 6차 점검 E2E 실측: 주문 리스크 가드(_drawdown_pre_order_check)가 account_key만으로 + PM을 열어 전역 10M 폴백 → 30만 pocket의 손실 한도가 사실상 무력화되던 결함. + """ + + def test_basket_key_resolves_basket_capital(self, monkeypatch): + import core.portfolio_manager as pm_mod + + monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + from unittest.mock import patch + with patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value={"kr_pocket": {"initial_capital": 300_000}}, + ): + pm = PortfolioManager(account_key="basket_rebalance:kr_pocket") + assert pm.initial_capital == 300_000 + + def test_unknown_basket_falls_back_to_global(self, monkeypatch): + import core.portfolio_manager as pm_mod + + monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + from unittest.mock import patch + with patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value={}, + ): + pm = PortfolioManager(account_key="basket_rebalance:no_such") + assert pm.initial_capital >= 1_000_000 # 전역 폴백(설정값) + + def test_non_basket_key_unchanged(self, monkeypatch): + import core.portfolio_manager as pm_mod + + monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + pm = PortfolioManager(account_key="scoring") + assert pm.initial_capital >= 1_000_000 # 기존 동작 그대로 + + def test_explicit_capital_still_wins(self, monkeypatch): + import core.portfolio_manager as pm_mod + + monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + pm = PortfolioManager( + account_key="basket_rebalance:kr_pocket", initial_capital=777, + ) + assert pm.initial_capital == 777 + + class TestSummaryWithDeposits: def test_no_flows_keeps_legacy_formula(self, monkeypatch): # 입금 0건: 총평가/초기자본 그대로 (하위 호환)