"""FX(환율) 리스크 관리 모듈 테스트 테스트 시나리오: 1. calculate_usd_exposure() — USD 노출 비율 계산 2. check_fx_risk() — 환율 변동에 따른 리스크 레벨 판별 3. should_allow_us_buy() — 노출 한도/FX 리스크 기반 매수 허용 여부 4. calculate_fx_impact() — 환율 변동에 의한 손익 계산 5. 엣지 케이스 — 제로 자산, 미국 보유 없음 참고: FX 리스크 관리 모듈이 아직 구현되지 않았을 수 있으므로, 모듈의 인터페이스를 정의하고 테스트합니다. 실제 모듈 구현 시 이 테스트를 기준으로 합니다. """ import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import pytest from unittest.mock import patch, MagicMock # ── FX 리스크 관리 함수 (모듈 미구현 시 자체 정의) ──────────────────────────── def calculate_usd_exposure( us_holdings_krw: float, total_assets_krw: float, ) -> float: """USD 노출 비율 계산. Args: us_holdings_krw: 미국 주식 보유 금액 (KRW 환산) total_assets_krw: 전체 자산 (KRW) Returns: 노출 비율 (0.0 ~ 1.0). 자산 0이면 0.0 반환. """ if total_assets_krw <= 0: return 0.0 return us_holdings_krw / total_assets_krw def check_fx_risk( current_rate: float, avg_rate_30d: float, avg_rate_90d: float, ) -> str: """환율 변동에 따른 리스크 레벨 판별. Args: current_rate: 현재 USD/KRW 환율 avg_rate_30d: 30일 평균 환율 avg_rate_90d: 90일 평균 환율 Returns: 리스크 레벨: 'LOW', 'MEDIUM', 'HIGH' """ if avg_rate_30d <= 0 or avg_rate_90d <= 0: return "HIGH" change_30d = abs(current_rate - avg_rate_30d) / avg_rate_30d change_90d = abs(current_rate - avg_rate_90d) / avg_rate_90d if change_30d >= 0.05 or change_90d >= 0.08: return "HIGH" elif change_30d >= 0.02 or change_90d >= 0.04: return "MEDIUM" else: return "LOW" def should_allow_us_buy( us_holdings_krw: float, total_assets_krw: float, current_rate: float, avg_rate_30d: float, avg_rate_90d: float, max_usd_exposure: float = 0.30, ) -> tuple: """미국 주식 매수 허용 여부 판단. Args: us_holdings_krw: 미국 주식 보유 금액 (KRW) total_assets_krw: 전체 자산 (KRW) current_rate: 현재 USD/KRW 환율 avg_rate_30d: 30일 평균 환율 avg_rate_90d: 90일 평균 환율 max_usd_exposure: 최대 USD 노출 비율 (기본 30%) Returns: (허용 여부 bool, 사유 str) """ exposure = calculate_usd_exposure(us_holdings_krw, total_assets_krw) fx_risk = check_fx_risk(current_rate, avg_rate_30d, avg_rate_90d) if exposure >= max_usd_exposure: return False, f"USD 노출 한도 초과: {exposure:.1%} >= {max_usd_exposure:.1%}" if fx_risk == "HIGH": return False, f"FX 리스크 HIGH: 환율 변동성 과대" return True, "매수 허용" def calculate_fx_impact( entry_rate: float, current_rate: float, position_usd: float, ) -> dict: """환율 변동에 의한 손익 계산. Args: entry_rate: 진입 시 환율 (USD/KRW) current_rate: 현재 환율 (USD/KRW) position_usd: 포지션 크기 (USD) Returns: dict: - fx_pnl_krw: 환율 손익 (KRW) - fx_pnl_pct: 환율 손익률 (%) - rate_change_pct: 환율 변동률 (%) """ if entry_rate <= 0 or position_usd <= 0: return {"fx_pnl_krw": 0.0, "fx_pnl_pct": 0.0, "rate_change_pct": 0.0} rate_change = current_rate - entry_rate rate_change_pct = (rate_change / entry_rate) * 100 fx_pnl_krw = rate_change * position_usd fx_pnl_pct = rate_change_pct # 환율 변동률 = FX 손익률 return { "fx_pnl_krw": round(fx_pnl_krw, 0), "fx_pnl_pct": round(fx_pnl_pct, 2), "rate_change_pct": round(rate_change_pct, 2), } # ─ calculate_usd_exposure 테스트 ────────────────────────────────────────────── class TestCalculateUsdExposure: """USD 노출 비율 테스트""" def test_basic_exposure(self): """기본 노출 비율 계산""" result = calculate_usd_exposure( us_holdings_krw=30_000_000, total_assets_krw=100_000_000, ) assert abs(result - 0.30) < 1e-6 def test_zero_holdings(self): """미국 보유 없음 → 0%""" result = calculate_usd_exposure( us_holdings_krw=0, total_assets_krw=100_000_000, ) assert result == 0.0 def test_zero_total_assets(self): """총 자산 0 → 0%""" result = calculate_usd_exposure( us_holdings_krw=10_000_000, total_assets_krw=0, ) assert result == 0.0 def test_negative_total_assets(self): """총 자산 음수 → 0%""" result = calculate_usd_exposure( us_holdings_krw=10_000_000, total_assets_krw=-50_000_000, ) assert result == 0.0 def test_full_exposure(self): """100% USD 노출""" result = calculate_usd_exposure( us_holdings_krw=100_000_000, total_assets_krw=100_000_000, ) assert abs(result - 1.0) < 1e-6 # ─ check_fx_risk 테스트 ────────────────────────────────────────────────────── class TestCheckFxRisk: """FX 리스크 레벨 판별 테스트""" def test_low_risk_stable_rate(self): """안정적 환율 → LOW""" result = check_fx_risk( current_rate=1350.0, avg_rate_30d=1345.0, # < 2% 변동 avg_rate_90d=1340.0, # < 4% 변동 ) assert result == "LOW" def test_medium_risk_moderate_change(self): """중간 변동 → MEDIUM""" result = check_fx_risk( current_rate=1380.0, avg_rate_30d=1350.0, # ~2.2% 변동 avg_rate_90d=1340.0, # ~3% 변동 ) assert result == "MEDIUM" def test_high_risk_large_30d_change(self): """30일 대비 5% 이상 변동 → HIGH""" result = check_fx_risk( current_rate=1420.0, avg_rate_30d=1350.0, # ~5.2% 변동 avg_rate_90d=1340.0, ) assert result == "HIGH" def test_high_risk_large_90d_change(self): """90일 대비 8% 이상 변동 → HIGH""" result = check_fx_risk( current_rate=1460.0, avg_rate_30d=1440.0, # < 2% avg_rate_90d=1350.0, # ~8.1% 변동 ) assert result == "HIGH" def test_high_risk_zero_avg_rate(self): """평균 환율 0 → HIGH (안전 장치)""" result = check_fx_risk( current_rate=1350.0, avg_rate_30d=0, avg_rate_90d=1340.0, ) assert result == "HIGH" def test_won_appreciation_also_detected(self): """원화 강세 (환율 하락)도 리스크로 감지""" result = check_fx_risk( current_rate=1280.0, avg_rate_30d=1350.0, # ~5.2% 변동 (절대값) avg_rate_90d=1350.0, ) assert result == "HIGH" # ─ should_allow_us_buy 테스트 ──────────────────────────────────────────────── class TestShouldAllowUsBuy: """미국 주식 매수 허용 여부 테스트""" def test_allow_normal_conditions(self): """정상 상태에서 매수 허용""" allowed, reason = should_allow_us_buy( us_holdings_krw=10_000_000, total_assets_krw=100_000_000, current_rate=1350.0, avg_rate_30d=1345.0, avg_rate_90d=1340.0, ) assert allowed is True assert "허용" in reason def test_block_exposure_exceeded(self): """노출 한도 초과 시 매수 거부""" allowed, reason = should_allow_us_buy( us_holdings_krw=31_000_000, # 31% total_assets_krw=100_000_000, current_rate=1350.0, avg_rate_30d=1345.0, avg_rate_90d=1340.0, max_usd_exposure=0.30, ) assert allowed is False assert "한도" in reason def test_block_high_fx_risk(self): """FX 리스크 HIGH 시 매수 거부""" allowed, reason = should_allow_us_buy( us_holdings_krw=10_000_000, total_assets_krw=100_000_000, current_rate=1420.0, avg_rate_30d=1350.0, # ~5.2% 변동 avg_rate_90d=1340.0, ) assert allowed is False assert "HIGH" in reason or "리스크" in reason def test_allow_medium_fx_risk(self): """FX 리스크 MEDIUM이면 매수 허용""" allowed, reason = should_allow_us_buy( us_holdings_krw=10_000_000, total_assets_krw=100_000_000, current_rate=1380.0, avg_rate_30d=1350.0, # ~2.2% 변동 avg_rate_90d=1340.0, ) assert allowed is True def test_block_zero_assets(self): """총 자산 0 — 노출 0%이므로 허용 (하지만 FX 체크도 필요)""" allowed, reason = should_allow_us_buy( us_holdings_krw=0, total_assets_krw=0, current_rate=1350.0, avg_rate_30d=1345.0, avg_rate_90d=1340.0, ) # 총 자산 0이면 노출 0% → 한도 미초과 → FX 체크만 적용 assert allowed is True def test_custom_max_exposure(self): """커스텀 최대 노출 비율""" allowed, _ = should_allow_us_buy( us_holdings_krw=15_000_000, # 15% total_assets_krw=100_000_000, current_rate=1350.0, avg_rate_30d=1345.0, avg_rate_90d=1340.0, max_usd_exposure=0.10, # 10% 한도 ) assert allowed is False # ─ calculate_fx_impact 테스트 ──────────────────────────────────────────────── class TestCalculateFxImpact: """환율 변동 손익 계산 테스트""" def test_won_depreciation_profit(self): """원화 약세 (환율 상승) → FX 이익""" result = calculate_fx_impact( entry_rate=1300.0, current_rate=1350.0, position_usd=1000.0, ) assert result["fx_pnl_krw"] == 50_000 # (1350-1300) * 1000 assert result["fx_pnl_pct"] > 0 assert abs(result["rate_change_pct"] - 3.85) < 0.1 def test_won_appreciation_loss(self): """원화 강세 (환율 하락) → FX 손실""" result = calculate_fx_impact( entry_rate=1350.0, current_rate=1300.0, position_usd=1000.0, ) assert result["fx_pnl_krw"] == -50_000 assert result["fx_pnl_pct"] < 0 def test_no_rate_change(self): """환율 변동 없음 → 손익 0""" result = calculate_fx_impact( entry_rate=1350.0, current_rate=1350.0, position_usd=1000.0, ) assert result["fx_pnl_krw"] == 0 assert result["fx_pnl_pct"] == 0 assert result["rate_change_pct"] == 0 def test_zero_entry_rate(self): """진입 환율 0 → 안전 기본값""" result = calculate_fx_impact( entry_rate=0, current_rate=1350.0, position_usd=1000.0, ) assert result["fx_pnl_krw"] == 0 assert result["fx_pnl_pct"] == 0 def test_zero_position(self): """포지션 0 → 손익 0""" result = calculate_fx_impact( entry_rate=1350.0, current_rate=1400.0, position_usd=0, ) assert result["fx_pnl_krw"] == 0 def test_large_position(self): """대규모 포지션 FX 손익""" result = calculate_fx_impact( entry_rate=1300.0, current_rate=1400.0, position_usd=100_000.0, # $100,000 ) # (1400-1300) * 100,000 = 10,000,000 KRW assert result["fx_pnl_krw"] == 10_000_000 def test_result_contains_all_keys(self): """반환 딕셔너리에 필수 키 존재""" result = calculate_fx_impact( entry_rate=1350.0, current_rate=1400.0, position_usd=1000.0, ) assert "fx_pnl_krw" in result assert "fx_pnl_pct" in result assert "rate_change_pct" in result if __name__ == "__main__": pytest.main([__file__, "-v"])