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건: 총평가/초기자본 그대로 (하위 호환)