From c823e9646f21d9c10235d035c5ae3c95358fd0a5 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Thu, 11 Jun 2026 10:56:42 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=90=EB=B3=B8=20=EB=B6=80=EC=A1=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=BB=20=EC=B1=84=EC=9A=B0=EB=8A=94=20?= =?UTF-8?q?=EB=B0=94=EC=8A=A4=EC=BC=93=20=EC=8A=AC=EB=A1=AF=EC=9D=98=20?= =?UTF-8?q?=EC=B9=A8=EB=AC=B5=20=EC=8A=A4=ED=82=B5=20=EA=B0=80=EC=8B=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 2일차 점검에서 발견: kr_diversified_hold의 000660(SK하이닉스) 슬롯이 비어 있는데(9/10 종목) 어떤 경고도 없었다. 원인 — 목표 거래금액(자본 1,000만 × 실효 8% = 80만원)이 1주 가격(213만원)보다 작아 quantity=0으로 조용히 continue. 드리프트 트리거는 매 사이클 발동하지만 계획 단계에서 침묵 스킵돼, 운영자는 배분이 설계(10종목 80% 주식)와 다르게(9종목 72%) 굳은 것을 모른다. 수정: 매수 의도(drift>0)인데 0주인 슬롯은 명시 경고 — 1주 가격 vs 목표 금액, 자본 증액 또는 비중 조정 필요를 로그로 드러낸다(일일 스케줄 작업이 이상 징후 로 보고). 코드 동작(주문 생성)은 불변 — 가시화만. 테스트: 고가 1종목+일반 1종목 계획에서 일반 종목만 주문 생성되는 경계 고정. 전체 스위트 통과. 참고(운영자 결정 사항, PR 본문에 선택지): 트랙레코드 자체는 실보유를 정직하게 기록 중이라 무결성 문제는 없다 — 다만 4.5년 백테스트(10종목 80%)와 paper (9종목 72%)의 구성 괴리는 평가 시 인지해야 할 사실. --- core/basket_rebalancer.py | 10 +++++++++ tests/test_basket_rebalancer.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 6220132..b42f2c8 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -380,6 +380,16 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder continue quantity = int(trade_value / price) if quantity <= 0: + if drift > 0: + # 1주 가격이 목표 거래금액을 초과 — 현재 자본 규모로는 이 슬롯을 + # 영원히 채울 수 없다(예: 자본 1,000만·목표 8%=80만 < SK하이닉스 + # 1주 213만). 침묵 스킵하면 운영자가 모른 채 배분이 설계와 + # 달라진다 — 자본 증액 또는 비중 조정이 필요한 운영자 결정 사항. + logger.warning( + "종목 {} 채움 불가: 1주 가격 {:,.0f}원 > 목표 거래금액 {:,.0f}원 " + "— 자본 증액 또는 baskets.yaml 비중 조정 필요 (현재 미보유 비중 {:.1%})", + symbol, price, trade_value, drift, + ) continue if drift > 0: candidates.append((RebalanceOrder( diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index deb3430..77ed805 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -659,3 +659,43 @@ def boom(strategy=""): cfg.get_account_no = boom issues = check_basket_account_isolation(["a", "b"], cfg, "live") assert len(issues) == 1 and "fail-closed" in issues[0] + + +class TestUnfillableSlotWarning: + """1주 가격 > 목표 거래금액이라 0주가 되는 슬롯의 침묵 스킵 가시화. + + 실사례(2026-06-11): 자본 1,000만·실효 목표 8%=80만원 < SK하이닉스 1주 213만원 + → 000660 슬롯이 경고 없이 영원히 비었다. 운영자 결정(자본 증액/비중 조정)이 + 필요한 상태는 반드시 경고로 드러나야 한다. + """ + + def test_warns_when_single_share_exceeds_target_amount(self, caplog): + from types import SimpleNamespace + from unittest.mock import patch + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "t" + rb.basket_cfg = { + "holdings": {"000660": 0.5, "005930": 0.5}, + "rebalance": {"trigger": "drift", "drift_threshold": 0.08, + "min_trade_amount": 200000, "max_turnover_ratio": 1.0}, + } + rb.rebalance_cfg = rb.basket_cfg["rebalance"] + rb.account_key = "t" + rb.execution_strategy = "t" + rb.portfolio_mgr = SimpleNamespace( + get_portfolio_summary=lambda current_prices=None: {"total_value": 1_000_000}, + ) + rb._is_live = lambda: False + rb._stock_fraction = lambda: 1.0 + rb.get_target_weights = lambda: {"000660": 0.5, "005930": 0.5} + rb.get_current_weights = lambda prices=None: {} + + prices = {"000660": 2_129_000, "005930": 60_000} + with patch("core.basket_rebalancer.get_all_positions", return_value=[]): + orders = rb.plan_rebalance(prices=prices) + + # 005930은 매수 가능(50만/6만=8주), 000660은 0주 — 주문엔 없어야 한다 + symbols = [o.symbol for o in orders] + assert "005930" in symbols and "000660" not in symbols