"""환율 리스크 관리 모듈 (FX Risk Manager) 미국 주식 투자 시 환율 변동 리스크를 관리. [관리 항목] - USD 익스포저 모니터링 (전체 자산 대비 USD 비중) - 환율 변동 임팩트 계산 - 환율 급변 시 경고 [규칙] - USD 익스포저 최대 30% (설정 가능) - 환율 변동 +-2% 이상 시 신규 미국 주식 매수 경고 - 환율 하락 시 (원화 강세) 미국 주식 매수 유리 -> 비중 확대 허용 """ import logging from typing import Dict, List, Optional logger = logging.getLogger(__name__) # ── 기본 설정값 ───────────────────────────────────────────────────────────────── DEFAULT_MAX_USD_EXPOSURE_PCT = 30.0 # USD 익스포저 최대 30% DEFAULT_FX_LOW_THRESHOLD = 1.0 # +-1% = low DEFAULT_FX_MEDIUM_THRESHOLD = 2.0 # +-2% = medium (warning) DEFAULT_FX_HIGH_THRESHOLD = 3.0 # +-3%+ = high (block) class FxRiskManager: """환율 리스크 관리자 (싱글톤으로 사용)""" def __init__( self, max_usd_exposure_pct: float = DEFAULT_MAX_USD_EXPOSURE_PCT, fx_low_threshold: float = DEFAULT_FX_LOW_THRESHOLD, fx_medium_threshold: float = DEFAULT_FX_MEDIUM_THRESHOLD, fx_high_threshold: float = DEFAULT_FX_HIGH_THRESHOLD, ): self.max_usd_exposure_pct = max_usd_exposure_pct self.fx_low_threshold = fx_low_threshold self.fx_medium_threshold = fx_medium_threshold self.fx_high_threshold = fx_high_threshold def calculate_usd_exposure( self, portfolio_items: List[Dict], exchange_rate: float, ) -> Dict: """USD 익스포저 계산 Args: portfolio_items: 포트폴리오 종목 리스트 각 항목: {"ticker": str, "market": "US"/"KR", "quantity": int, "current_price": float, "currency": "USD"/"KRW"} exchange_rate: 현재 USD/KRW 환율 (예: 1350.0) Returns: {"usd_value": float, "krw_equivalent": float, "exposure_pct": float, "total_assets_krw": float} """ total_usd_value = 0.0 total_krw_value = 0.0 for item in portfolio_items: qty = item.get("quantity", 0) price = item.get("current_price", 0.0) currency = item.get("currency", "KRW") if currency == "USD": item_usd = qty * price total_usd_value += item_usd total_krw_value += item_usd * exchange_rate else: total_krw_value += qty * price krw_equivalent = total_usd_value * exchange_rate total_assets_krw = total_krw_value exposure_pct = (krw_equivalent / total_assets_krw * 100) if total_assets_krw > 0 else 0.0 result = { "usd_value": round(total_usd_value, 2), "krw_equivalent": round(krw_equivalent, 0), "exposure_pct": round(exposure_pct, 2), "total_assets_krw": round(total_assets_krw, 0), } logger.debug( f"[FX_EXPOSURE] USD ${total_usd_value:,.2f} = " f"KRW {krw_equivalent:,.0f} ({exposure_pct:.1f}%)" ) return result def check_fx_risk( self, current_rate: float, avg_rate_30d: Optional[float] = None, ) -> Dict: """환율 변동 리스크 평가 Args: current_rate: 현재 USD/KRW 환율 avg_rate_30d: 30일 평균 환율 (없으면 리스크 판단 불가 -> low 반환) Returns: {"fx_change_pct": float, "risk_level": "low"/"medium"/"high", "recommendation": str} """ if avg_rate_30d is None or avg_rate_30d <= 0: return { "fx_change_pct": 0.0, "risk_level": "low", "recommendation": "30일 평균 환율 데이터 없음 - 기본 허용", } fx_change_pct = ((current_rate - avg_rate_30d) / avg_rate_30d) * 100 abs_change = abs(fx_change_pct) # 리스크 레벨 판단 if abs_change >= self.fx_high_threshold: risk_level = "high" if fx_change_pct > 0: recommendation = ( f"환율 급등 ({fx_change_pct:+.1f}%) - " "신규 미국 주식 매수 차단 권고 (원화 약세)" ) else: recommendation = ( f"환율 급락 ({fx_change_pct:+.1f}%) - " "신규 미국 주식 매수 차단 권고 (환율 불안정)" ) elif abs_change >= self.fx_medium_threshold: risk_level = "medium" if fx_change_pct > 0: recommendation = ( f"환율 상승 ({fx_change_pct:+.1f}%) - " "미국 주식 매수 시 주의 (환전 비용 증가)" ) else: recommendation = ( f"환율 하락 ({fx_change_pct:+.1f}%) - " "미국 주식 매수 유리한 구간 (원화 강세)" ) else: risk_level = "low" recommendation = f"환율 안정 ({fx_change_pct:+.1f}%) - 정상 매매 가능" result = { "fx_change_pct": round(fx_change_pct, 2), "risk_level": risk_level, "recommendation": recommendation, } if risk_level != "low": logger.info( f"[FX_RISK] {risk_level.upper()}: " f"현재 {current_rate:.1f} vs 30일평균 {avg_rate_30d:.1f} " f"({fx_change_pct:+.1f}%)" ) return result def should_allow_us_buy( self, total_assets_krw: float, current_us_value_krw: float, new_buy_krw: float, fx_risk_level: str, ) -> Dict: """미국 주식 신규 매수 허용 여부 판단 Args: total_assets_krw: 전체 자산 (KRW) current_us_value_krw: 현재 미국 주식 보유분 (KRW 환산) new_buy_krw: 신규 매수 금액 (KRW 환산) fx_risk_level: 환율 리스크 레벨 ("low"/"medium"/"high") Returns: {"allowed": bool, "reason": str, "current_exposure_pct": float} """ if total_assets_krw <= 0: return { "allowed": False, "reason": "전체 자산 정보 없음", "current_exposure_pct": 0.0, } current_exposure_pct = (current_us_value_krw / total_assets_krw) * 100 new_exposure_pct = ((current_us_value_krw + new_buy_krw) / total_assets_krw) * 100 # 1) 환율 리스크 high -> 차단 if fx_risk_level == "high": reason = ( f"환율 리스크 HIGH - 신규 미국 주식 매수 차단 " f"(현재 익스포저: {current_exposure_pct:.1f}%)" ) logger.warning(f"[FX_BLOCK] {reason}") return { "allowed": False, "reason": reason, "current_exposure_pct": round(current_exposure_pct, 2), } # 2) 최대 USD 익스포저 초과 체크 if new_exposure_pct > self.max_usd_exposure_pct: reason = ( f"USD 익스포저 한도 초과: " f"매수 후 {new_exposure_pct:.1f}% > 최대 {self.max_usd_exposure_pct:.1f}% " f"(현재 {current_exposure_pct:.1f}%)" ) logger.info(f"[FX_LIMIT] {reason}") return { "allowed": False, "reason": reason, "current_exposure_pct": round(current_exposure_pct, 2), } # 3) 허용 reason = ( f"매수 허용: 익스포저 {current_exposure_pct:.1f}% -> {new_exposure_pct:.1f}% " f"(한도 {self.max_usd_exposure_pct:.1f}%, FX리스크 {fx_risk_level})" ) return { "allowed": True, "reason": reason, "current_exposure_pct": round(current_exposure_pct, 2), } def calculate_fx_impact( self, entry_rate: float, current_rate: float, usd_value: float, ) -> Dict: """환율 변동으로 인한 손익 계산 Args: entry_rate: 매수 시점 USD/KRW 환율 current_rate: 현재 USD/KRW 환율 usd_value: 보유 USD 금액 Returns: {"fx_profit_loss_krw": float, "fx_impact_pct": float} """ if entry_rate <= 0: return {"fx_profit_loss_krw": 0.0, "fx_impact_pct": 0.0} # 환율 변동에 의한 KRW 손익 krw_at_entry = usd_value * entry_rate krw_at_current = usd_value * current_rate fx_profit_loss_krw = krw_at_current - krw_at_entry # 환율 변동률 fx_impact_pct = ((current_rate - entry_rate) / entry_rate) * 100 result = { "fx_profit_loss_krw": round(fx_profit_loss_krw, 0), "fx_impact_pct": round(fx_impact_pct, 2), } if abs(fx_impact_pct) >= 1.0: logger.info( f"[FX_IMPACT] USD ${usd_value:,.2f}: " f"환율 {entry_rate:.1f} -> {current_rate:.1f} " f"({fx_impact_pct:+.2f}%) = KRW {fx_profit_loss_krw:+,.0f}" ) return result # 싱글톤 인스턴스 fx_risk_manager = FxRiskManager()