## 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>
407 lines
13 KiB
Python
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"])
|