1010from loguru import logger
1111
1212from config .config_loader import Config
13+ from core .instrument_classes import is_non_company_symbol
1314
1415
1516def _get_tick_size (price : float ) -> int :
@@ -613,19 +614,39 @@ def check_projected_exposure(
613614 existing_position_value : float = 0 ,
614615 is_new_position : bool = True ,
615616 symbol : str = "" ,
617+ exposure_limits : dict | None = None ,
616618 ) -> dict :
617- """모든 BUY 경로가 공유하는 숫자 기반 최종 노출 상한 검사."""
619+ """모든 BUY 경로가 공유하는 숫자 기반 최종 노출 상한 검사.
620+
621+ exposure_limits: 사전 승인된 목표 비중표가 있는 주문에 한해 전역 상한 대신
622+ 쓸 {max_position_ratio, max_investment_ratio}. 전역값은 재량 매매용
623+ 안전판이라, 비중을 명시로 선언한 바스켓에는 설계 자체를 불가능하게 만든다
624+ (kr_pocket 설계 47.5%/95% vs 전역 20%/70% — 2026-08-26에 적립금이 매수로
625+ 전환되지 못하는 형태로 드러났다). 상한을 없애는 게 아니라 그 바스켓이
626+ 선언한 비중 + 허용 드리프트로 바꿔 다는 것이다.
627+ """
618628 div_config = self .risk_params .get ("diversification" , {})
629+ limits = exposure_limits or {}
619630 try :
620631 raw_max_positions = div_config .get ("max_positions" , 10 )
621632 if isinstance (raw_max_positions , bool ):
622633 raise ValueError ("boolean max_positions" )
623634 max_positions = int (raw_max_positions )
624- max_ratio = float (div_config .get ("max_position_ratio" , 0.20 ))
635+ max_ratio = float (
636+ limits .get ("max_position_ratio" )
637+ if limits .get ("max_position_ratio" ) is not None
638+ else div_config .get ("max_position_ratio" , 0.20 )
639+ )
625640 max_investment_ratio = float (
626- div_config .get ("max_investment_ratio" , 0.70 )
641+ limits .get ("max_investment_ratio" )
642+ if limits .get ("max_investment_ratio" ) is not None
643+ else div_config .get ("max_investment_ratio" , 0.70 )
644+ )
645+ min_cash = float (
646+ limits .get ("min_cash_ratio" )
647+ if limits .get ("min_cash_ratio" ) is not None
648+ else div_config .get ("min_cash_ratio" , 0.20 )
627649 )
628- min_cash = float (div_config .get ("min_cash_ratio" , 0.20 ))
629650 current_positions = int (current_positions )
630651 position_value = self ._value_in_krw_for_symbol (
631652 symbol , float (position_value )
@@ -728,6 +749,7 @@ def check_diversification(
728749 positions : list | None = None ,
729750 existing_position_value : float = 0 ,
730751 is_new_position : bool = True ,
752+ exposure_limits : dict | None = None ,
731753 ) -> dict :
732754 """
733755 분산 투자 규칙 확인 (종목 수·비중·투자비율·현금 + 업종 비중)
@@ -767,6 +789,7 @@ def check_diversification(
767789 existing_position_value = existing_position_value ,
768790 is_new_position = is_new_position ,
769791 symbol = symbol ,
792+ exposure_limits = exposure_limits ,
770793 )
771794 if not exposure ["can_buy" ]:
772795 return exposure
@@ -787,6 +810,14 @@ def check_diversification(
787810 "can_buy" : False ,
788811 "reason" : "업종 비중 설정 오류: max_sector_ratio는 (0,1]이어야 함" ,
789812 }
813+ # 업종 분산은 '개별 기업'에 대한 개념이다. 지수 ETF는 그 자체가 여러 업종에
814+ # 걸친 묶음이고, 금리 파킹 ETF는 주식이 아니다 — KRX 업종 코드가 아예 없다.
815+ # 그런 종목을 sector_map_strict의 fail-closed에 걸면 '업종 매핑 없음'으로
816+ # 영원히 매수 불가가 된다(2026-08-26: kr_pocket에 적립금 10만원이 들어와도
817+ # 069500/357870 매수가 전부 이 사유로 거부됐다). 면제 목록으로 명시한다.
818+ if symbol and is_non_company_symbol (symbol , self .risk_params ):
819+ max_sector_ratio = None
820+
790821 if (
791822 max_sector_ratio is not None
792823 and total_value > 0
0 commit comments