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>
This commit is contained in:
270
backend/app/services/fx_risk_manager.py
Normal file
270
backend/app/services/fx_risk_manager.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""환율 리스크 관리 모듈 (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()
|
||||
Reference in New Issue
Block a user