Files
auto-stock/backend/tests/test_fx_risk_manager.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

407 lines
13 KiB
Python

"""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"])