diff --git a/config/risk_params.yaml b/config/risk_params.yaml index d1d37d03..477f175b 100644 --- a/config/risk_params.yaml +++ b/config/risk_params.yaml @@ -65,6 +65,7 @@ diversification: max_investment_ratio: 0.70 # 전체 주식 투자 비중 상한 (총 자산의 70% 초과 불가) max_sector_ratio: 0.40 # 단일 업종(KRX Sector) 최대 비중 (총자산 대비 40%). FDR 업종 매핑 사용. sector_map_strict: true # 업종 맵 조회 실패/누락 시 신규 BUY 차단 (fail-closed) + # 업종 면제는 instrument_classes.non_company_symbols(파일 하단)를 참조한다. max_positions: 10 # 최대 동시 보유 종목 수 (이 개수 초과 시 신규 매수 불가) min_cash_ratio: 0.20 # 최소 현금 비중 (20%) # 상관관계 기반 리스크 관리: 기존 보유 종목과 높은 상관관계를 보이는 종목의 비중 축소 @@ -185,3 +186,16 @@ transaction_costs: warn_slippage_multiplier: 2.0 critical_at_volume_ratio: 0.03 # 3% 이상 주문 시 고슬리피지 배수 (소형주 대량 주문 가정) critical_slippage_multiplier: 4.0 + +# --- 종목 성격 분류 --- +# 개별 기업이 아닌 종목(ETF·펀드). 기업 단위로만 의미가 있는 필터는 여기 등록된 +# 종목을 건너뛴다 — 업종 비중(KRX Sector 코드 없음), 실적 발표일(실적일·DART 기업코드 +# 없음)이 그 대상이다. 두 필터 다 fail-closed라, 등록하지 않으면 '매핑 없음 / 조회 불가' +# 사유로 영원히 매수가 거부된다. 실제로 kr_pocket(ETF 2종)이 이 상태여서 적립금이 +# 들어와도 매수로 전환되지 못했다(2026-08-26 확인). +# +# 면제되는 것은 위 두 가지뿐이다. 노출 상한·유동성·갭·현금·거래중단은 그대로 적용된다. +instrument_classes: + non_company_symbols: + - "069500" # KODEX 200 — 200종목 지수 ETF + - "357870" # TIGER CD금리투자KIS(합성) — 금리 파킹, 주식 아님 diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 9d98fea0..f9cc76a6 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -439,6 +439,41 @@ def _deployment_gap(self, prices: dict[str, float] = None) -> float | None: logger.debug("배치율 격차 계산 실패: {}", exc) return None + def _policy_exposure_limits(self, symbol: str) -> dict | None: + """이 바스켓이 선언한 비중에서 파생한 노출 상한. 선언이 없으면 None(전역 사용). + + 전역 상한(max_position_ratio 20%, max_investment_ratio 70%)은 재량 매매용 + 안전판이라, 비중을 명시로 선언한 바스켓에는 설계를 실행 불가능하게 만든다. + kr_pocket 설계는 종목당 47.5% / 투자 95%인데 전역은 20% / 70%다 — 그래서 + 적립금 10만원이 들어와도 매수가 '단일 종목 비중 20% 초과'로 전부 거부됐다 + (2026-08-26 확인). 상한을 없애는 게 아니라 선언한 비중 + 허용 드리프트로 + 바꿔 단다. 어떤 경우에도 목표를 드리프트 임계값 이상 넘길 수는 없다. + """ + targets = self.get_target_weights() + target_w = targets.get(symbol) + if target_w is None: + return None + stock_fraction = self._stock_fraction() + drift = float(self.rebalance_cfg.get("drift_threshold", 0.05)) + band = float(self.rebalance_cfg.get("deployment_band", 0.03)) + # 현금 하한도 이 바스켓이 선언한 값을 쓴다. 전역 20%는 개별 주식 바스켓용 + # 안전판인데, 보유분 절반이 현금성(CD 파킹 ETF)인 kr_pocket에는 과잉이라 + # 설계(현금 5%)를 실행 불가능하게 만든다. effective_stock_fraction과 같은 + # 해석 규칙을 쓴다. + div_cfg = (self._risk_params or {}).get("diversification", {}) or {} + raw_mcr = self.basket.get("min_cash_ratio", div_cfg.get("min_cash_ratio", 0.20)) + try: + min_cash = max(0.0, min(1.0, float(raw_mcr))) + except (TypeError, ValueError): + min_cash = float(div_cfg.get("min_cash_ratio", 0.20)) + + return { + # 슬리브 내 비중 상한을 총자산 기준으로 환산 + "max_position_ratio": min(1.0, (float(target_w) + drift) * stock_fraction), + "max_investment_ratio": min(1.0, stock_fraction + band), + "min_cash_ratio": min_cash, + } + # ------------------------------------------------------------------ # 리스크 청산 (손절/익절/트레일링) # ------------------------------------------------------------------ @@ -781,6 +816,8 @@ def execute( weight_policy_managed=True, # 진입 레벨도 트랙 정책으로 기록한다(전역 단타 -3% 손절 금지). risk_levels=basket_risk_levels(self.basket, order.price), + # 노출 상한도 이 바스켓이 선언한 비중에서 파생한다. + exposure_limits=self._policy_exposure_limits(order.symbol), ) else: res = executor.execute_sell( diff --git a/core/instrument_classes.py b/core/instrument_classes.py new file mode 100644 index 00000000..1373f64a --- /dev/null +++ b/core/instrument_classes.py @@ -0,0 +1,40 @@ +"""종목 성격 분류 — '개별 기업'을 전제로 한 필터를 어디에 적용하지 않을지 한 곳에 둔다. + +이 저장소에서 반복된 실패 패턴 하나: 개별 주식용으로 만든 전역 안전장치가 ETF를 만나면 +설계를 실행 불가능하게 만든다. 2026-08-26에 kr_pocket(ETF 2종)에서 네 개가 연달아 걸렸다. + + 쌍별 상관 거부권 → weight_policy_managed로 위임 (해결) + 단일 종목 20% 상한 → 선언 비중 기반 상한으로 위임 (해결) + 업종 비중(KRX Sector) → ETF는 업종 코드가 없다 ← 이 모듈 + 실적 발표일 필터 → ETF는 실적일·기업코드가 없다 ← 이 모듈 + +뒤 두 개는 '기업 단위'로만 의미가 있는 검사다. 지수 ETF는 그 자체가 여러 업종에 걸친 +묶음이고, 금리 파킹 ETF는 주식도 아니다. 둘 다 fail-closed라 등록하지 않으면 '매핑 없음 / +조회 불가' 사유로 매수가 영원히 거부된다. + +면제되는 것은 그 두 검사뿐이다. 노출 상한·유동성·갭·현금·거래중단은 그대로 적용된다. +목록은 config/risk_params.yaml의 instrument_classes.non_company_symbols에 둔다. +""" + +from __future__ import annotations + +from typing import Any + + +def non_company_symbols(risk_params: dict[str, Any] | None) -> set[str]: + """개별 기업이 아닌 종목(ETF·펀드) 코드 집합. 미설정이면 빈 집합.""" + classes = (risk_params or {}).get("instrument_classes") or {} + raw = classes.get("non_company_symbols") or [] + if isinstance(raw, (str, bytes)): + return set() + try: + return {str(s).strip() for s in raw if str(s).strip()} + except TypeError: + return set() + + +def is_non_company_symbol(symbol: str, risk_params: dict[str, Any] | None) -> bool: + """이 종목이 기업 단위 필터(업종·실적)의 면제 대상인가.""" + if not symbol: + return False + return str(symbol).strip() in non_company_symbols(risk_params) diff --git a/core/order_executor.py b/core/order_executor.py index c800bfbb..2e997492 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -16,6 +16,7 @@ from config.config_loader import Config from api.kis_api import KISApi, KISOrderResponseUnknown +from core.instrument_classes import is_non_company_symbol from core.risk_manager import RiskManager from database.repositories import ( save_trade, save_position, delete_position, reduce_position, delete_trade_by_id, @@ -1952,6 +1953,7 @@ def execute_buy_quantity( execution_session_id: str = "", weight_policy_managed: bool = False, risk_levels: dict = None, + exposure_limits: dict = None, ) -> dict: """Execute a fixed-quantity buy (paper or live). @@ -1983,6 +1985,7 @@ def execute_buy_quantity( execution_session_id=execution_session_id, weight_policy_managed=weight_policy_managed, risk_levels=risk_levels, + exposure_limits=exposure_limits, ) def _execute_buy_quantity_impl( @@ -2000,6 +2003,7 @@ def _execute_buy_quantity_impl( execution_session_id: str = "", weight_policy_managed: bool = False, risk_levels: dict = None, + exposure_limits: dict = None, ) -> dict: # live 고정수량 BUY도 일반 BUY와 동일하게 canonical live gate 통과 executor에서만 허용. # (기존 paper-only 차단을 제거하면서 이 게이트가 그 안전 역할을 승계한다.) @@ -2135,6 +2139,13 @@ def _execute_buy_quantity_impl( str(getattr(position, "symbol", "")) == str(symbol) for position in positions ), + # 전역 상한은 재량 매매용 안전판이다. 목표 비중을 명시로 선언한 바스켓에는 + # 그 설계 자체를 불가능하게 만든다 — kr_pocket 설계는 종목당 47.5% / 투자 + # 95%인데 전역은 20% / 70%라, 적립금이 들어와도 매수가 전부 거부됐다. + # 상한을 없애는 게 아니라 '그 바스켓이 선언한 비중 + 허용 드리프트'로 + # 바꿔 단다(계산은 호출부인 리밸런서가 한다). 승인된 비중표가 없는 주문은 + # 그대로 전역 상한을 쓴다. + exposure_limits=exposure_limits if weight_policy_managed else None, ) if not exposure_check["can_buy"]: return { @@ -2189,6 +2200,13 @@ def _execute_buy_quantity_impl( "reason": f"실적 발표일 필터 설정 오류: {exc}", "earnings_filter_blocked": True, } + # ETF·펀드는 실적 발표일도 DART 기업코드도 없다. 이 필터는 fail-closed라 + # 그대로 두면 '실적일 조회 불가'로 영원히 매수가 막힌다(kr_pocket 실측). + if skip_earnings_days > 0 and is_non_company_symbol( + symbol, self.config.risk_params + ): + logger.debug("종목 {} 실적 필터 면제 — 개별 기업이 아님(ETF/펀드)", symbol) + skip_earnings_days = 0 if skip_earnings_days > 0: try: from core.earnings_filter import is_near_earnings diff --git a/core/risk_manager.py b/core/risk_manager.py index 8730c124..4ff2f31c 100644 --- a/core/risk_manager.py +++ b/core/risk_manager.py @@ -10,6 +10,7 @@ from loguru import logger from config.config_loader import Config +from core.instrument_classes import is_non_company_symbol def _get_tick_size(price: float) -> int: @@ -613,19 +614,39 @@ def check_projected_exposure( existing_position_value: float = 0, is_new_position: bool = True, symbol: str = "", + exposure_limits: dict | None = None, ) -> dict: - """모든 BUY 경로가 공유하는 숫자 기반 최종 노출 상한 검사.""" + """모든 BUY 경로가 공유하는 숫자 기반 최종 노출 상한 검사. + + exposure_limits: 사전 승인된 목표 비중표가 있는 주문에 한해 전역 상한 대신 + 쓸 {max_position_ratio, max_investment_ratio}. 전역값은 재량 매매용 + 안전판이라, 비중을 명시로 선언한 바스켓에는 설계 자체를 불가능하게 만든다 + (kr_pocket 설계 47.5%/95% vs 전역 20%/70% — 2026-08-26에 적립금이 매수로 + 전환되지 못하는 형태로 드러났다). 상한을 없애는 게 아니라 그 바스켓이 + 선언한 비중 + 허용 드리프트로 바꿔 다는 것이다. + """ div_config = self.risk_params.get("diversification", {}) + limits = exposure_limits or {} try: raw_max_positions = div_config.get("max_positions", 10) if isinstance(raw_max_positions, bool): raise ValueError("boolean max_positions") max_positions = int(raw_max_positions) - max_ratio = float(div_config.get("max_position_ratio", 0.20)) + max_ratio = float( + limits.get("max_position_ratio") + if limits.get("max_position_ratio") is not None + else div_config.get("max_position_ratio", 0.20) + ) max_investment_ratio = float( - div_config.get("max_investment_ratio", 0.70) + limits.get("max_investment_ratio") + if limits.get("max_investment_ratio") is not None + else div_config.get("max_investment_ratio", 0.70) + ) + min_cash = float( + limits.get("min_cash_ratio") + if limits.get("min_cash_ratio") is not None + else div_config.get("min_cash_ratio", 0.20) ) - min_cash = float(div_config.get("min_cash_ratio", 0.20)) current_positions = int(current_positions) position_value = self._value_in_krw_for_symbol( symbol, float(position_value) @@ -728,6 +749,7 @@ def check_diversification( positions: list | None = None, existing_position_value: float = 0, is_new_position: bool = True, + exposure_limits: dict | None = None, ) -> dict: """ 분산 투자 규칙 확인 (종목 수·비중·투자비율·현금 + 업종 비중) @@ -767,6 +789,7 @@ def check_diversification( existing_position_value=existing_position_value, is_new_position=is_new_position, symbol=symbol, + exposure_limits=exposure_limits, ) if not exposure["can_buy"]: return exposure @@ -787,6 +810,14 @@ def check_diversification( "can_buy": False, "reason": "업종 비중 설정 오류: max_sector_ratio는 (0,1]이어야 함", } + # 업종 분산은 '개별 기업'에 대한 개념이다. 지수 ETF는 그 자체가 여러 업종에 + # 걸친 묶음이고, 금리 파킹 ETF는 주식이 아니다 — KRX 업종 코드가 아예 없다. + # 그런 종목을 sector_map_strict의 fail-closed에 걸면 '업종 매핑 없음'으로 + # 영원히 매수 불가가 된다(2026-08-26: kr_pocket에 적립금 10만원이 들어와도 + # 069500/357870 매수가 전부 이 사유로 거부됐다). 면제 목록으로 명시한다. + if symbol and is_non_company_symbol(symbol, self.risk_params): + max_sector_ratio = None + if ( max_sector_ratio is not None and total_value > 0 diff --git a/tests/test_policy_exposure_limits.py b/tests/test_policy_exposure_limits.py new file mode 100644 index 00000000..98b30319 --- /dev/null +++ b/tests/test_policy_exposure_limits.py @@ -0,0 +1,243 @@ +"""선언한 비중표가 전역 안전 파라미터에 막히지 않는지 검증한다. + +배경(2026-08-26): kr_pocket에 첫 적립금 10만원을 넣었는데 매수로 전환되지 않았다. +개별 주식용 전역 파라미터가 ETF 2종 바스켓의 설계를 차례로 막았기 때문이다. + + 단일 종목 20% 상한 vs 선언 47.5% → '단일 종목 비중 20% 초과' + 전체 투자 70% 상한 vs 선언 95% → (위를 통과했어도 곧 걸림) + 최소 현금 20% vs 선언 5% → '최소 현금 비중 20% 미만' + 업종 비중(KRX) ETF는 업종 코드 없음 → '업종 매핑 없음' + 실적 발표일 필터 ETF는 실적일 없음 → '실적일 조회 불가' + +다섯 개 다 fail-closed라 조용히 '매수 0건'으로만 나타났다. 전역값은 재량 매매용 +안전판이므로, 비중을 명시로 선언한 주문에는 그 선언에서 파생한 상한으로 바꿔 단다. +상한을 없애는 게 아니다 — 어떤 경우에도 목표를 드리프트 임계값 이상 넘길 수 없다. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.basket_rebalancer import BasketRebalancer +from core.instrument_classes import is_non_company_symbol, non_company_symbols +from core.risk_manager import RiskManager + + +class _Cfg: + def __init__(self, risk_params): + self.risk_params = risk_params + self.trading = {"mode": "paper"} + self.settings = {} + + +def _rm(**div): + base = { + "max_position_ratio": 0.20, + "max_investment_ratio": 0.70, + "min_cash_ratio": 0.20, + "max_positions": 10, + "sector_map_strict": True, + "max_sector_ratio": 0.40, + } + base.update(div) + return RiskManager(_Cfg({"diversification": base})) + + +# ------------------------------------------------------- 종목 성격 분류 + +def test_non_company_symbols_reads_config(): + rp = {"instrument_classes": {"non_company_symbols": ["069500", "357870"]}} + assert non_company_symbols(rp) == {"069500", "357870"} + assert is_non_company_symbol("069500", rp) is True + assert is_non_company_symbol("005930", rp) is False + + +@pytest.mark.parametrize("rp", [ + None, {}, {"instrument_classes": None}, {"instrument_classes": {}}, + {"instrument_classes": {"non_company_symbols": None}}, + {"instrument_classes": {"non_company_symbols": "069500"}}, # 문자열은 무시 +]) +def test_non_company_symbols_malformed_config_is_empty(rp): + assert non_company_symbols(rp) == set() + assert is_non_company_symbol("069500", rp) is False + + +def test_empty_symbol_is_never_exempt(): + rp = {"instrument_classes": {"non_company_symbols": ["069500"]}} + assert is_non_company_symbol("", rp) is False + + +# ------------------------------------------------------- 노출 상한 위임 + +def _exposure(rm, **kw): + args = dict( + current_positions=1, position_value=100_000, total_value=1_000_000, + available_cash=900_000, current_invested=100_000, symbol="069500", + existing_position_value=100_000, is_new_position=False, + ) + args.update(kw) + return rm.check_projected_exposure(**args) + + +def test_global_position_cap_blocks_without_override(): + """기본 동작은 그대로 — 전역 20% 상한이 살아 있어야 한다.""" + r = _exposure(_rm(), position_value=150_000) # 기존 10만 + 15만 = 25% + assert r["can_buy"] is False + assert "단일 종목" in r["reason"] + + +def test_declared_weight_override_allows_the_design(): + """선언 비중에서 파생한 상한을 주면 설계대로 담을 수 있다.""" + r = _exposure( + _rm(), position_value=150_000, + exposure_limits={"max_position_ratio": 0.55}, + ) + assert r["can_buy"] is True + + +def test_override_still_caps_beyond_declared_weight(): + """위임은 무제한이 아니다 — 준 상한을 넘으면 여전히 막는다.""" + r = _exposure( + _rm(), position_value=600_000, # 기존 10만 + 60만 = 70% + exposure_limits={"max_position_ratio": 0.55}, + ) + assert r["can_buy"] is False + + +def test_investment_and_cash_overrides(): + rm = _rm() + blocked = _exposure(rm, position_value=250_000, current_invested=500_000) + assert blocked["can_buy"] is False + + allowed = _exposure( + rm, position_value=250_000, current_invested=500_000, + exposure_limits={ + "max_position_ratio": 0.55, + "max_investment_ratio": 0.98, + "min_cash_ratio": 0.05, + }, + ) + assert allowed["can_buy"] is True, allowed["reason"] + + +def test_partial_override_falls_back_to_global_for_unset_keys(): + """일부만 지정하면 나머지는 전역값을 그대로 쓴다.""" + r = _exposure( + _rm(), position_value=150_000, current_invested=650_000, + exposure_limits={"max_position_ratio": 0.55}, # 투자비율은 미지정 + ) + assert r["can_buy"] is False + assert "전체 투자 비중" in r["reason"] + + +# ------------------------------------------------------- 업종 검사 면제 + +def _sector_args(**kw): + args = dict( + current_positions=1, position_value=100_000, total_value=1_000_000, + available_cash=900_000, current_invested=100_000, symbol="069500", + sector_map={"005930": "전기전자"}, positions=[], + existing_position_value=100_000, is_new_position=False, + ) + args.update(kw) + return args + + +def test_unmapped_stock_is_still_blocked_fail_closed(): + """개별 주식의 업종 매핑 누락은 계속 fail-closed여야 한다.""" + rm = _rm() + rm.risk_params["instrument_classes"] = {"non_company_symbols": ["069500"]} + r = rm.check_diversification(**_sector_args(symbol="000660")) + assert r["can_buy"] is False + assert "업종" in r["reason"] + + +def test_non_company_symbol_skips_sector_check(): + """ETF는 KRX 업종 코드가 없다 — 면제 목록에 있으면 통과한다.""" + rm = _rm() + rm.risk_params["instrument_classes"] = {"non_company_symbols": ["069500"]} + r = rm.check_diversification(**_sector_args(symbol="069500")) + assert r["can_buy"] is True, r["reason"] + + +# ------------------------------------- 리밸런서가 만드는 상한이 설계와 맞는가 + +def _rebalancer(basket): + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "t" + rb.basket = basket + rb.holdings = basket["holdings"] + rb.rebalance_cfg = basket.get("rebalance", {}) + rb._target_stock_weight = basket.get("target_stock_weight") + rb._risk_params = {"diversification": {"min_cash_ratio": 0.20}} + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + return rb + + +def test_policy_limits_make_pocket_design_reachable(): + """kr_pocket 설계(종목 47.5% / 투자 95% / 현금 5%)가 상한 안에 들어와야 한다.""" + rb = _rebalancer({ + "target_stock_weight": 0.95, + "min_cash_ratio": 0.05, + "holdings": {"069500": 0.5, "357870": 0.5}, + "rebalance": {"drift_threshold": 0.08, "deployment_band": 0.03}, + }) + limits = rb._policy_exposure_limits("069500") + assert limits["max_position_ratio"] >= 0.475 + assert limits["max_investment_ratio"] >= 0.95 + assert limits["min_cash_ratio"] == pytest.approx(0.05) + + +def test_policy_limits_do_not_exceed_target_plus_drift(): + """상한은 목표 + 드리프트 임계값을 넘지 않는다(무제한 위임 금지).""" + rb = _rebalancer({ + "target_stock_weight": 0.60, + "holdings": {f"A{i}": 1 / 9 for i in range(9)}, + "rebalance": {"drift_threshold": 0.08, "deployment_band": 0.03}, + }) + limits = rb._policy_exposure_limits("A0") + assert limits["max_position_ratio"] == pytest.approx((1 / 9 + 0.08) * 0.60) + assert limits["max_position_ratio"] < 0.20 + + +def test_policy_limits_none_for_unknown_symbol(): + """비중표에 없는 종목은 위임하지 않는다(전역 상한 유지).""" + rb = _rebalancer({ + "target_stock_weight": 0.60, + "holdings": {"005930": 1.0}, + "rebalance": {}, + }) + assert rb._policy_exposure_limits("999999") is None + + +def test_policy_limits_bad_min_cash_falls_back_to_global(): + rb = _rebalancer({ + "target_stock_weight": 0.60, + "min_cash_ratio": "많이", + "holdings": {"005930": 1.0}, + "rebalance": {}, + }) + assert rb._policy_exposure_limits("005930")["min_cash_ratio"] == pytest.approx(0.20) + + +# ------------------------------------------------- 운영 설정 불변식 + +def test_shipped_etf_baskets_are_actually_buyable(): + """운영 중인 ETF 바스켓의 종목이 기업 단위 필터에 막히지 않아야 한다. + + 이 불변식이 깨지면 적립금이 들어와도 매수 0건이 되고, 증상은 '배치율 미달'로만 + 보여 원인이 가려진다. + """ + from config.config_loader import Config + + risk_params = Config.get().risk_params + baskets = BasketRebalancer._load_baskets_config() + pocket = baskets.get("kr_pocket") + assert pocket and pocket.get("enabled"), "kr_pocket이 없거나 비활성" + for symbol in pocket["holdings"]: + assert is_non_company_symbol(symbol, risk_params), ( + f"{symbol}이 instrument_classes.non_company_symbols에 없다 — " + "업종·실적 필터에 fail-closed로 막힌다" + )