Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions config/risk_params.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%)
# 상관관계 기반 리스크 관리: 기존 보유 종목과 높은 상관관계를 보이는 종목의 비중 축소
Expand Down Expand Up @@ -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(합성) — 금리 파킹, 주식 아님
37 changes: 37 additions & 0 deletions core/basket_rebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

# ------------------------------------------------------------------
# 리스크 청산 (손절/익절/트레일링)
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions core/instrument_classes.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions core/order_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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(
Expand All @@ -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 차단을 제거하면서 이 게이트가 그 안전 역할을 승계한다.)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions core/risk_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
"""
분산 투자 규칙 확인 (종목 수·비중·투자비율·현금 + 업종 비중)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading