Skip to content

Commit 32320a8

Browse files
authored
Merge pull request #457 from easygap/fix/pocket-contribution-blockers
fix: 적립금이 매수로 전환되지 못하던 다섯 개 차단 해소
2 parents bd8d74d + 5750aa9 commit 32320a8

6 files changed

Lines changed: 387 additions & 4 deletions

File tree

config/risk_params.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ diversification:
6565
max_investment_ratio: 0.70 # 전체 주식 투자 비중 상한 (총 자산의 70% 초과 불가)
6666
max_sector_ratio: 0.40 # 단일 업종(KRX Sector) 최대 비중 (총자산 대비 40%). FDR 업종 매핑 사용.
6767
sector_map_strict: true # 업종 맵 조회 실패/누락 시 신규 BUY 차단 (fail-closed)
68+
# 업종 면제는 instrument_classes.non_company_symbols(파일 하단)를 참조한다.
6869
max_positions: 10 # 최대 동시 보유 종목 수 (이 개수 초과 시 신규 매수 불가)
6970
min_cash_ratio: 0.20 # 최소 현금 비중 (20%)
7071
# 상관관계 기반 리스크 관리: 기존 보유 종목과 높은 상관관계를 보이는 종목의 비중 축소
@@ -185,3 +186,16 @@ transaction_costs:
185186
warn_slippage_multiplier: 2.0
186187
critical_at_volume_ratio: 0.03 # 3% 이상 주문 시 고슬리피지 배수 (소형주 대량 주문 가정)
187188
critical_slippage_multiplier: 4.0
189+
190+
# --- 종목 성격 분류 ---
191+
# 개별 기업이 아닌 종목(ETF·펀드). 기업 단위로만 의미가 있는 필터는 여기 등록된
192+
# 종목을 건너뛴다 — 업종 비중(KRX Sector 코드 없음), 실적 발표일(실적일·DART 기업코드
193+
# 없음)이 그 대상이다. 두 필터 다 fail-closed라, 등록하지 않으면 '매핑 없음 / 조회 불가'
194+
# 사유로 영원히 매수가 거부된다. 실제로 kr_pocket(ETF 2종)이 이 상태여서 적립금이
195+
# 들어와도 매수로 전환되지 못했다(2026-08-26 확인).
196+
#
197+
# 면제되는 것은 위 두 가지뿐이다. 노출 상한·유동성·갭·현금·거래중단은 그대로 적용된다.
198+
instrument_classes:
199+
non_company_symbols:
200+
- "069500" # KODEX 200 — 200종목 지수 ETF
201+
- "357870" # TIGER CD금리투자KIS(합성) — 금리 파킹, 주식 아님

core/basket_rebalancer.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,41 @@ def _deployment_gap(self, prices: dict[str, float] = None) -> float | None:
439439
logger.debug("배치율 격차 계산 실패: {}", exc)
440440
return None
441441

442+
def _policy_exposure_limits(self, symbol: str) -> dict | None:
443+
"""이 바스켓이 선언한 비중에서 파생한 노출 상한. 선언이 없으면 None(전역 사용).
444+
445+
전역 상한(max_position_ratio 20%, max_investment_ratio 70%)은 재량 매매용
446+
안전판이라, 비중을 명시로 선언한 바스켓에는 설계를 실행 불가능하게 만든다.
447+
kr_pocket 설계는 종목당 47.5% / 투자 95%인데 전역은 20% / 70%다 — 그래서
448+
적립금 10만원이 들어와도 매수가 '단일 종목 비중 20% 초과'로 전부 거부됐다
449+
(2026-08-26 확인). 상한을 없애는 게 아니라 선언한 비중 + 허용 드리프트로
450+
바꿔 단다. 어떤 경우에도 목표를 드리프트 임계값 이상 넘길 수는 없다.
451+
"""
452+
targets = self.get_target_weights()
453+
target_w = targets.get(symbol)
454+
if target_w is None:
455+
return None
456+
stock_fraction = self._stock_fraction()
457+
drift = float(self.rebalance_cfg.get("drift_threshold", 0.05))
458+
band = float(self.rebalance_cfg.get("deployment_band", 0.03))
459+
# 현금 하한도 이 바스켓이 선언한 값을 쓴다. 전역 20%는 개별 주식 바스켓용
460+
# 안전판인데, 보유분 절반이 현금성(CD 파킹 ETF)인 kr_pocket에는 과잉이라
461+
# 설계(현금 5%)를 실행 불가능하게 만든다. effective_stock_fraction과 같은
462+
# 해석 규칙을 쓴다.
463+
div_cfg = (self._risk_params or {}).get("diversification", {}) or {}
464+
raw_mcr = self.basket.get("min_cash_ratio", div_cfg.get("min_cash_ratio", 0.20))
465+
try:
466+
min_cash = max(0.0, min(1.0, float(raw_mcr)))
467+
except (TypeError, ValueError):
468+
min_cash = float(div_cfg.get("min_cash_ratio", 0.20))
469+
470+
return {
471+
# 슬리브 내 비중 상한을 총자산 기준으로 환산
472+
"max_position_ratio": min(1.0, (float(target_w) + drift) * stock_fraction),
473+
"max_investment_ratio": min(1.0, stock_fraction + band),
474+
"min_cash_ratio": min_cash,
475+
}
476+
442477
# ------------------------------------------------------------------
443478
# 리스크 청산 (손절/익절/트레일링)
444479
# ------------------------------------------------------------------
@@ -781,6 +816,8 @@ def execute(
781816
weight_policy_managed=True,
782817
# 진입 레벨도 트랙 정책으로 기록한다(전역 단타 -3% 손절 금지).
783818
risk_levels=basket_risk_levels(self.basket, order.price),
819+
# 노출 상한도 이 바스켓이 선언한 비중에서 파생한다.
820+
exposure_limits=self._policy_exposure_limits(order.symbol),
784821
)
785822
else:
786823
res = executor.execute_sell(

core/instrument_classes.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""종목 성격 분류 — '개별 기업'을 전제로 한 필터를 어디에 적용하지 않을지 한 곳에 둔다.
2+
3+
이 저장소에서 반복된 실패 패턴 하나: 개별 주식용으로 만든 전역 안전장치가 ETF를 만나면
4+
설계를 실행 불가능하게 만든다. 2026-08-26에 kr_pocket(ETF 2종)에서 네 개가 연달아 걸렸다.
5+
6+
쌍별 상관 거부권 → weight_policy_managed로 위임 (해결)
7+
단일 종목 20% 상한 → 선언 비중 기반 상한으로 위임 (해결)
8+
업종 비중(KRX Sector) → ETF는 업종 코드가 없다 ← 이 모듈
9+
실적 발표일 필터 → ETF는 실적일·기업코드가 없다 ← 이 모듈
10+
11+
뒤 두 개는 '기업 단위'로만 의미가 있는 검사다. 지수 ETF는 그 자체가 여러 업종에 걸친
12+
묶음이고, 금리 파킹 ETF는 주식도 아니다. 둘 다 fail-closed라 등록하지 않으면 '매핑 없음 /
13+
조회 불가' 사유로 매수가 영원히 거부된다.
14+
15+
면제되는 것은 그 두 검사뿐이다. 노출 상한·유동성·갭·현금·거래중단은 그대로 적용된다.
16+
목록은 config/risk_params.yaml의 instrument_classes.non_company_symbols에 둔다.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import Any
22+
23+
24+
def non_company_symbols(risk_params: dict[str, Any] | None) -> set[str]:
25+
"""개별 기업이 아닌 종목(ETF·펀드) 코드 집합. 미설정이면 빈 집합."""
26+
classes = (risk_params or {}).get("instrument_classes") or {}
27+
raw = classes.get("non_company_symbols") or []
28+
if isinstance(raw, (str, bytes)):
29+
return set()
30+
try:
31+
return {str(s).strip() for s in raw if str(s).strip()}
32+
except TypeError:
33+
return set()
34+
35+
36+
def is_non_company_symbol(symbol: str, risk_params: dict[str, Any] | None) -> bool:
37+
"""이 종목이 기업 단위 필터(업종·실적)의 면제 대상인가."""
38+
if not symbol:
39+
return False
40+
return str(symbol).strip() in non_company_symbols(risk_params)

core/order_executor.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from config.config_loader import Config
1818
from api.kis_api import KISApi, KISOrderResponseUnknown
19+
from core.instrument_classes import is_non_company_symbol
1920
from core.risk_manager import RiskManager
2021
from database.repositories import (
2122
save_trade, save_position, delete_position, reduce_position, delete_trade_by_id,
@@ -1952,6 +1953,7 @@ def execute_buy_quantity(
19521953
execution_session_id: str = "",
19531954
weight_policy_managed: bool = False,
19541955
risk_levels: dict = None,
1956+
exposure_limits: dict = None,
19551957
) -> dict:
19561958
"""Execute a fixed-quantity buy (paper or live).
19571959
@@ -1983,6 +1985,7 @@ def execute_buy_quantity(
19831985
execution_session_id=execution_session_id,
19841986
weight_policy_managed=weight_policy_managed,
19851987
risk_levels=risk_levels,
1988+
exposure_limits=exposure_limits,
19861989
)
19871990

19881991
def _execute_buy_quantity_impl(
@@ -2000,6 +2003,7 @@ def _execute_buy_quantity_impl(
20002003
execution_session_id: str = "",
20012004
weight_policy_managed: bool = False,
20022005
risk_levels: dict = None,
2006+
exposure_limits: dict = None,
20032007
) -> dict:
20042008
# live 고정수량 BUY도 일반 BUY와 동일하게 canonical live gate 통과 executor에서만 허용.
20052009
# (기존 paper-only 차단을 제거하면서 이 게이트가 그 안전 역할을 승계한다.)
@@ -2135,6 +2139,13 @@ def _execute_buy_quantity_impl(
21352139
str(getattr(position, "symbol", "")) == str(symbol)
21362140
for position in positions
21372141
),
2142+
# 전역 상한은 재량 매매용 안전판이다. 목표 비중을 명시로 선언한 바스켓에는
2143+
# 그 설계 자체를 불가능하게 만든다 — kr_pocket 설계는 종목당 47.5% / 투자
2144+
# 95%인데 전역은 20% / 70%라, 적립금이 들어와도 매수가 전부 거부됐다.
2145+
# 상한을 없애는 게 아니라 '그 바스켓이 선언한 비중 + 허용 드리프트'로
2146+
# 바꿔 단다(계산은 호출부인 리밸런서가 한다). 승인된 비중표가 없는 주문은
2147+
# 그대로 전역 상한을 쓴다.
2148+
exposure_limits=exposure_limits if weight_policy_managed else None,
21382149
)
21392150
if not exposure_check["can_buy"]:
21402151
return {
@@ -2189,6 +2200,13 @@ def _execute_buy_quantity_impl(
21892200
"reason": f"실적 발표일 필터 설정 오류: {exc}",
21902201
"earnings_filter_blocked": True,
21912202
}
2203+
# ETF·펀드는 실적 발표일도 DART 기업코드도 없다. 이 필터는 fail-closed라
2204+
# 그대로 두면 '실적일 조회 불가'로 영원히 매수가 막힌다(kr_pocket 실측).
2205+
if skip_earnings_days > 0 and is_non_company_symbol(
2206+
symbol, self.config.risk_params
2207+
):
2208+
logger.debug("종목 {} 실적 필터 면제 — 개별 기업이 아님(ETF/펀드)", symbol)
2209+
skip_earnings_days = 0
21922210
if skip_earnings_days > 0:
21932211
try:
21942212
from core.earnings_filter import is_near_earnings

core/risk_manager.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from loguru import logger
1111

1212
from config.config_loader import Config
13+
from core.instrument_classes import is_non_company_symbol
1314

1415

1516
def _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

Comments
 (0)