"""공용 매수 가드 (수동 매수 경로용) — CLAUDE.md §5.1. [H-4 2026-10-02] 수동 매수(`manual_buy.py` / `portfolio_v2.py`)는 가격 필터·밴·MDD· 보유수만 확인하고 아래를 모두 빠뜨리고 있었다. - 종목당 4% **누적** 상한(기존 보유 평가액 차감) — 1건 상한만 있었고, `quantity` 를 지정하면 그마저 없었다 - 현금 유보(`MAX_TOTAL_CAPITAL_USAGE`, 95% 사용 상한) - 레버리지/인버스 ETF 20%(`LEVERAGE_ETF_MAX_RATIO`) - 최소 주문금액 실효값(`effective_min_buy_amount`) - 적응형 Crisis 모드(신규 진입 전면 차단) / 적응형 포지션 상한(`min(adp, 4%)`) - 섹터·상관관계 필터(`correlation_filter`) - 현금 필드: `dnca_tot_amt`(D+2 예수금, 당일 매수 많으면 음수) 를 1순위로 썼다 — `portfolio.py` 의 신뢰 순서상 최하위다 이 모듈은 위 가드를 **한 곳**에서 판정한다. 정의는 `trade_executor._execute_buy` 의 대응 단계와 같다(같은 sizing_core 원시 연산·같은 계수). trade_executor 는 다른 작업이 편집 중이라 수정하지 않았고, 이후 그쪽이 이 모듈을 쓰도록 이관하는 것을 권장한다. 규칙 - 가드 판정은 try 밖. I/O(잔고·매수가능금액·상관 필터)만 개별 try 로 감싸고, 실패하면 `BuyGuardRejected` 로 **매수 거부**(fail-closed)한다 — 검증할 수 없는 한도는 통과시키지 않는다. - 지정 수량은 거부하지 않고 **클램프**(초과분 축소)하고 사유를 반환한다. - 한도 숫자는 전부 settings 소유(하드코딩 없음). 청산 경로는 이 모듈을 호출하지 않는다. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Any, Iterable, List, Optional, Tuple from app.core.config import settings from app.services.sizing_core import ( amount_to_quantity, effective_min_buy_amount, remaining_position_room, ) from app.services.trade_helpers import get_leverage_check_keywords, is_leverage_etf logger = logging.getLogger(__name__) # effective_min_buy_amount 의 소액계좌 완화 계수 — trade_executor._execute_buy / # _apply_position_cap_room_v2 와 동일 값(정의 일치가 목적이므로 같은 상수를 쓴다). _MIN_BUY_SMALL_ACCOUNT_FACTOR = 0.9 # 예수금 필드 신뢰 순서 — app/api/endpoints/portfolio.py `_cash_keys` 와 동일 순서 # (`_ord_psbl_cash` 는 매수가능조회 결과를 주입한 키라 여기서는 get_buyable_cash 로 대체). _CASH_KEYS_BY_TRUST = ( "prvs_rcdl_excc_amt", "nxdy_excc_amt", "d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", ) class BuyGuardRejected(Exception): """매수 거부. 엔드포인트가 HTTPException(status_code, detail) 로 변환한다.""" def __init__(self, status_code: int, reason: str, detail: str): super().__init__(detail) self.status_code = status_code self.reason = reason self.detail = detail @dataclass class BuyGuardDecision: quantity: int requested_quantity: Optional[int] budget: float available_cash: float total_assets: float position_cap_ratio: float min_buy_amount: float clamped: bool = False clamp_reasons: List[str] = field(default_factory=list) def to_response(self) -> dict: return { "requested_quantity": self.requested_quantity, "clamped": self.clamped, "clamp_reasons": list(self.clamp_reasons), "budget": round(self.budget), "position_cap_ratio": self.position_cap_ratio, } # ── 순수 판정 (I/O 없음) ───────────────────────────────────────────────────── def pick_available_cash_v2(output2: dict) -> float: """잔고 output2 에서 신뢰 순서대로 첫 양수 예수금을 고른다(없으면 0).""" for k in _CASH_KEYS_BY_TRUST: try: v = float(str((output2 or {}).get(k) or 0).replace(",", "")) except (TypeError, ValueError): continue if v > 0: return v return 0.0 async def resolve_available_cash_v2(kis: Any, output2: dict, stock_code: str) -> float: """주문가능 현금 단일 정의 (수동 매수·trade_executor 공용). 1순위 `kis.get_buyable_cash(stock_code)`(ord_psbl_cash, 당일 매도대금 포함), 0/실패면 잔고 output2 를 신뢰 순서(`_CASH_KEYS_BY_TRUST`, dnca_tot_amt 는 후순위)로. """ try: ord_psbl = float(await kis.get_buyable_cash(stock_code) or 0) except Exception as e: logger.warning(f"[CASH] {stock_code}: 매수가능금액 조회 실패 — 잔고 예수금으로 대체: {e}") ord_psbl = 0.0 return ord_psbl if ord_psbl > 0 else pick_available_cash_v2(output2 or {}) def existing_position_qty_v2( holding: Any, stock_code: str, include_dt_inmemory: bool = True ) -> int: """기존 보유 수량 = max(Portfolio 행 수량, DT 인메모리 수량). trade_executor._existing_position_qty_v2 와 같은 정의(합산하면 sync 이후 이중 계상). 멀티테넌트 사용자 컨텍스트는 DT 인메모리 상태가 레거시 계좌 소유라 제외한다. """ row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0 dt_qty = 0 if include_dt_inmemory: try: from app.services.daytrading.position_manager import day_trading_state as _dts dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0) except Exception as e: logger.warning( f"[BUY_GUARD] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {e}" ) return max(row_qty, dt_qty) def position_cap_room_v2( total_assets: float, position_cap_ratio: float, existing_qty: int, current_price: float, ) -> float: """종목당 **누적** 잔여 한도 = max(0, 총자산×cap − 기존 보유 평가액). trade_executor._apply_position_cap_room_v2 와 같은 sizing_core 연산. """ existing_value = max(0, int(existing_qty)) * float(current_price) return remaining_position_room(total_assets, position_cap_ratio, existing_value) def leverage_room_v2( stock_name: str, holdings: Iterable[Any], total_assets: float ) -> Optional[float]: """레버리지/인버스 ETF 잔여 한도. 대상 종목이 레버리지가 아니면 None(무제한). **이번 주문 포함** 20%(`LEVERAGE_ETF_MAX_RATIO`) 를 넘지 않게 잔여 한도로 묶는다. trade_executor(§7.7, `(보유 + invest_amount) / 총자산 > 한도` 이면 HOLD)와 같은 정의다 — 자동 경로는 초과 시 거부, 수동 경로는 지정 수량을 잔여 한도까지 클램프한다. """ kw = get_leverage_check_keywords() if not is_leverage_etf(stock_name, kw): return None lev_total = 0.0 for h in holdings or (): if is_leverage_etf(getattr(h, "stock_name", "") or "", kw): px = float(getattr(h, "current_price", 0) or getattr(h, "average_price", 0) or 0) lev_total += float(getattr(h, "quantity", 0) or 0) * px return max(0.0, total_assets * settings.LEVERAGE_ETF_MAX_RATIO - lev_total) def resolve_position_cap_ratio_v2() -> Tuple[float, Optional[str]]: """(종목당 상한 비율, 차단 사유). 적응형 상한은 MAX_POSITION_SIZE 로 클램프. 적응형 조회가 예외를 내면 Crisis 여부를 확인할 수 없으므로 거부한다(fail-closed). """ from app.services.adaptive_strategy import get_current_adaptive_params try: adp = get_current_adaptive_params() except Exception as e: logger.warning(f"[BUY_GUARD] 적응형 파라미터 조회 실패 — 매수 거부: {e}") return settings.MAX_POSITION_SIZE, "adaptive_unavailable" cap = settings.MAX_POSITION_SIZE if not getattr(adp, "fallback_used", True): cap = min(float(adp.position_size), settings.MAX_POSITION_SIZE) if adp.trading_mode == "crisis" and int(adp.max_daily_trades) <= 0: return cap, "adaptive_crisis_mode" return cap, None def size_manual_buy_v2( *, stock_code: str, order_price: float, current_price: float, requested_quantity: Optional[int], total_assets: float, available_cash: float, position_cap_ratio: float, existing_qty: int, leverage_room: Optional[float], ) -> BuyGuardDecision: """한도 교집합으로 매수 가능 수량 산출 (순수 함수, try 없음). 예산 = min( 종목당 누적 잔여(총자산×cap − 기존 보유 평가액), 가용현금 − 현금 유보(총자산×(1−MAX_TOTAL_CAPITAL_USAGE)), 레버리지 잔여(해당 시), ) 지정 수량은 예산 수량으로 클램프한다. 최소주문금액 미만이면 거부. """ if total_assets <= 0: raise BuyGuardRejected(502, "total_assets_unknown", "총자산을 확인할 수 없어 종목당 상한을 검증할 수 없습니다 — 매수 거부") if available_cash <= 0: raise BuyGuardRejected(400, "no_cash", "주문가능 현금이 없습니다") if order_price <= 0: raise BuyGuardRejected(400, "invalid_price", "유효하지 않은 주문가") min_buy = effective_min_buy_amount( config_min_amount=settings.MIN_BUY_AMOUNT_KRW, total_assets=total_assets, position_cap_ratio=position_cap_ratio, factor=_MIN_BUY_SMALL_ACCOUNT_FACTOR, ) caps: List[Tuple[str, float]] = [] pos_room = position_cap_room_v2(total_assets, position_cap_ratio, existing_qty, current_price) caps.append(( f"종목당 누적 상한 {position_cap_ratio:.1%} (기존 보유 {existing_qty}주 차감)", pos_room, )) cash_reserve = total_assets * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) caps.append(( f"현금 유보 {1 - settings.MAX_TOTAL_CAPITAL_USAGE:.0%} (가용현금 {available_cash:,.0f}원)", max(0.0, available_cash - cash_reserve), )) if leverage_room is not None: caps.append((f"레버리지 ETF 상한 {settings.LEVERAGE_ETF_MAX_RATIO:.0%}", leverage_room)) binding_label, budget = min(caps, key=lambda c: c[1]) budget = max(0.0, budget) if budget < min_buy: raise BuyGuardRejected( 400, "budget_below_min", f"매수 가능 예산 {budget:,.0f}원 < 최소주문 {min_buy:,.0f}원 — 제약: {binding_label}", ) max_qty = amount_to_quantity(budget, order_price) decision = BuyGuardDecision( quantity=max_qty, requested_quantity=requested_quantity, budget=budget, available_cash=available_cash, total_assets=total_assets, position_cap_ratio=position_cap_ratio, min_buy_amount=min_buy, ) if requested_quantity is not None: if requested_quantity > max_qty: decision.clamped = True decision.clamp_reasons.append( f"요청 {requested_quantity}주 → {max_qty}주 축소 ({binding_label}, 예산 {budget:,.0f}원)" ) logger.warning( f"[BUY_GUARD] {stock_code}: 지정 수량 클램프 {requested_quantity} → {max_qty}주 " f"— {binding_label}, 예산 {budget:,.0f}원" ) else: decision.quantity = int(requested_quantity) if decision.quantity < 1: raise BuyGuardRejected( 400, "quantity_zero", f"예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 — 제약: {binding_label}", ) if decision.quantity * order_price < min_buy: raise BuyGuardRejected( 400, "min_amount_guard", f"주문금액 {decision.quantity * order_price:,.0f}원 < 최소주문 {min_buy:,.0f}원", ) return decision # ── I/O 포함 진입점 ────────────────────────────────────────────────────────── async def evaluate_manual_buy_v2( *, kis: Any, stock_code: str, stock_name: str, current_price: float, order_price: float, requested_quantity: Optional[int], holding: Any, current_holdings: List[Any], include_dt_inmemory: bool = True, ) -> BuyGuardDecision: """수동 매수 공용 가드. 통과하면 주문 수량을, 아니면 BuyGuardRejected 를 던진다. 호출 전제: 가격 필터·loss_tracker 밴·MDD·보유수·TRADING_HALTED 는 엔드포인트가 이미 확인했다(기존 동작 유지). 여기서는 사이징·자본 계열 가드를 판정한다. """ # 1) 적응형 Crisis / 종목당 상한 cap_ratio, cap_block = resolve_position_cap_ratio_v2() if cap_block == "adaptive_crisis_mode": raise BuyGuardRejected(423, cap_block, "적응형 Crisis 모드 — 신규 진입 전면 차단 상태") if cap_block is not None: raise BuyGuardRejected(503, cap_block, "적응형 파라미터를 확인할 수 없어 매수를 거부합니다") # 2) 계좌 조회 (실패 → 거부) try: balance = await kis.get_balance() output2 = (balance or {}).get("output2") or [{}] b = output2[0] if isinstance(output2, list) else output2 b = b or {} total_assets = float(str(b.get("tot_evlu_amt") or 0).replace(",", "")) except Exception as e: logger.warning(f"[BUY_GUARD] {stock_code}: 잔고 조회 실패 — 매수 거부: {e}") raise BuyGuardRejected(502, "balance_unavailable", f"잔고 조회 실패: {e}") available_cash = await resolve_available_cash_v2(kis, b, stock_code) # 3) 섹터·상관관계 필터 (예외 → 거부) try: from app.services.correlation_filter import validate_correlation_filter # 섹터당 최대 종목 수·상관계수 임계값은 validate_correlation_filter 의 기본값이 # 정본이다(config 에 키가 없고 trade_executor 도 같은 값으로 폴백한다). corr_ok, corr_reason = await validate_correlation_filter( stock_code=stock_code, portfolio_holdings=current_holdings, ) except Exception as e: logger.warning(f"[BUY_GUARD] {stock_code}: 상관관계 필터 실패 — 매수 거부: {e}") raise BuyGuardRejected(503, "correlation_unavailable", f"섹터/상관관계 필터 확인 실패: {e}") if not corr_ok: raise BuyGuardRejected(400, "correlation_filter", f"섹터/상관관계 필터: {corr_reason}") # 4) 사이징 (순수 판정) existing_qty = existing_position_qty_v2(holding, stock_code, include_dt_inmemory) lev_room = leverage_room_v2(stock_name, current_holdings, total_assets) return size_manual_buy_v2( stock_code=stock_code, order_price=order_price, current_price=current_price, requested_quantity=requested_quantity, total_assets=total_assets, available_cash=available_cash, position_cap_ratio=cap_ratio, existing_qty=existing_qty, leverage_room=lev_room, )