""" kis_trader/utils/usd_krw_fx.py — USD→KRW 일자별 환율 (표시용) ================================================================ 해외 탭 UI에서 달러 금액 옆 원 환산용. 출처 기본: Frankfurter(ECB 기준가, 키 불필요). SafeRequest 로 429 재시도. ※ 매매 체결 환율·증권사 적용환율이 아님. 표시·참고 전용. """ from __future__ import annotations import json import logging import threading from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Set from kis_trader.utils.env import get_env_float, get_env_from_db, get_env_int from kis_trader.utils.request_handler import SafeRequest logger = logging.getLogger(__name__) _cache_lock = threading.Lock() _mem_rates: Dict[str, float] = {} def _api_base() -> str: # 공개 ECB 기준가 API — 키 불필요. DB/Env 로 교체 가능. return ( get_env_from_db("FX_USD_KRW_API_BASE", "https://api.frankfurter.app") or "" ).strip().rstrip("/") def _cache_dir() -> Path: raw = ( get_env_from_db("FX_USD_KRW_CACHE_DIR", "data/fx_usd_krw") or "data/fx_usd_krw" ).strip() p = Path(raw) if not p.is_absolute(): # 레포 루트 기준 root = Path(__file__).resolve().parents[2] p = root / p try: p.mkdir(parents=True, exist_ok=True) except Exception: pass return p def _fallback_rate() -> float: # API 실패 시 마지막 수단(표시만). 하드코딩 금지 → env. return float(get_env_float("FX_USD_KRW_FALLBACK", 1400.0)) def _http_enabled() -> bool: """외부(Frankfurter 등) HTTP 조회. 기본 OFF — 캐시·폴백만 (UI 지연 방지).""" raw = str(get_env_from_db("FX_USD_KRW_HTTP_ENABLED", "0") or "0").strip().lower() return raw in ("1", "true", "yes", "y", "on") def get_display_usd_krw_rate(ymd: str = "") -> float: """표시용 USD→KRW. 메모리→디스크→폴백만. HTTP 절대 호출 없음. 영구구독/해외탭 병기용. 매매 체결환율 아님. """ y = _norm_ymd(ymd) or datetime.now().strftime("%Y-%m-%d") with _cache_lock: if y in _mem_rates and _mem_rates[y] > 0: return float(_mem_rates[y]) disk = _read_disk(y) if disk is not None and disk > 0: with _cache_lock: _mem_rates[y] = disk return float(disk) try: dt = datetime.strptime(y, "%Y-%m-%d").date() except ValueError: return _fallback_rate() max_back = int(get_env_int("FX_USD_KRW_LOOKBACK_DAYS", 5)) for i in range(1, max_back + 1): prev = (dt - timedelta(days=i)).isoformat() with _cache_lock: if prev in _mem_rates and _mem_rates[prev] > 0: return float(_mem_rates[prev]) disk_prev = _read_disk(prev) if disk_prev is not None and disk_prev > 0: with _cache_lock: _mem_rates[prev] = disk_prev _mem_rates[y] = disk_prev return float(disk_prev) return _fallback_rate() def _norm_ymd(raw: Any) -> str: s = str(raw or "").strip() if not s: return "" s = s.replace("/", "-").replace(".", "-") if len(s) >= 10 and s[4] == "-" and s[7] == "-": return s[:10] digits = "".join(ch for ch in s if ch.isdigit()) if len(digits) >= 8: return f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}" return "" def _trade_fx_ymd(t: Dict[str, Any]) -> str: """청산일 우선, 없으면 매수일 — '그날의 환율'.""" for k in ( "sell_date", "sell_time", "exit_time", "exit_date", "buy_date", "buy_time", "entry_time", "entry_date", ): ymd = _norm_ymd(t.get(k)) if ymd: return ymd return "" class UsdKrwFxClient(SafeRequest): """Frankfurter 등 USD/KRW 일자별 조회 (표시용).""" def __init__(self) -> None: timeout = float(get_env_float("FX_USD_KRW_TIMEOUT_SEC", 12.0)) min_iv = float(get_env_float("FX_USD_KRW_MIN_INTERVAL_SEC", 0.2)) retries = int(get_env_int("FX_USD_KRW_MAX_RETRIES", 2)) super().__init__( min_interval_sec=min_iv, max_retries=retries, timeout_sec=timeout, ) def fetch_rate(self, ymd: str) -> Optional[float]: """성공 시 환율. 네트워크/HTTP 실패는 None. 휴일 후퇴는 get_usd_krw_rate 에서만.""" got = self.fetch_rate_detail(ymd) return got[0] if got else None def fetch_rate_detail(self, ymd: str) -> Optional[tuple]: """(rate, source_date) 또는 None. HTTP 4xx(데이터없음) vs 네트워크 실패 구분용 status도 로그.""" ymd = _norm_ymd(ymd) if not ymd: return None base = _api_base() if not base: return None url = f"{base}/{ymd}" try: resp = self.get(url, params={"from": "USD", "to": "KRW"}, timeout=self.timeout_sec) if resp is None or getattr(resp, "status_code", None) is None: return None if resp.status_code == 404: return None # 호출측 lookback if resp.status_code != 200: logger.warning("FX USD/KRW HTTP %s date=%s", resp.status_code, ymd) return None data = resp.json() rates = data.get("rates") or {} krw = rates.get("KRW") if krw is None: return None src = _norm_ymd(data.get("date")) or ymd return (float(krw), src) except Exception as e: logger.warning("FX USD/KRW fetch fail date=%s: %s", ymd, e) return None def norm_fx_ymd(raw: Any) -> str: """공개: YYYY-MM-DD 정규화 (API 쿼리용).""" return _norm_ymd(raw) def _read_disk(ymd: str) -> Optional[float]: path = _cache_dir() / f"{ymd}.json" if not path.is_file(): return None try: data = json.loads(path.read_text(encoding="utf-8")) v = data.get("rate") return float(v) if v is not None else None except Exception: return None def _write_disk(ymd: str, rate: float, source_date: str = "") -> None: path = _cache_dir() / f"{ymd}.json" try: path.write_text( json.dumps( { "ymd": ymd, "rate": rate, "source_date": source_date or ymd, "pair": "USD/KRW", }, ensure_ascii=False, ), encoding="utf-8", ) except Exception as e: logger.debug("FX cache write fail %s: %s", path, e) def get_usd_krw_rate(ymd: str, *, client: Optional[UsdKrwFxClient] = None) -> Optional[float]: """단일 일자 USD→KRW. Frankfurter 가 주말이면 직전 영업일 환율을 그 날짜 키로 캐시.""" ymd = _norm_ymd(ymd) if not ymd: return None with _cache_lock: if ymd in _mem_rates: return _mem_rates[ymd] disk = _read_disk(ymd) if disk is not None and disk > 0: with _cache_lock: _mem_rates[ymd] = disk return disk # 외부 HTTP 기본 OFF — UI/영구구독이 네트워크에 묶이지 않게 if not _http_enabled(): try: dt0 = datetime.strptime(ymd, "%Y-%m-%d").date() except ValueError: return None max_back0 = int(get_env_int("FX_USD_KRW_LOOKBACK_DAYS", 5)) for i in range(1, max_back0 + 1): prev0 = (dt0 - timedelta(days=i)).isoformat() with _cache_lock: if prev0 in _mem_rates and _mem_rates[prev0] > 0: rate0 = _mem_rates[prev0] _mem_rates[ymd] = rate0 return rate0 disk0 = _read_disk(prev0) if disk0 is not None and disk0 > 0: with _cache_lock: _mem_rates[prev0] = disk0 _mem_rates[ymd] = disk0 return disk0 return None cli = client or UsdKrwFxClient() detail = cli.fetch_rate_detail(ymd) if detail is not None: rate, src = detail if rate > 0: _write_disk(ymd, rate, source_date=src) with _cache_lock: _mem_rates[ymd] = rate return rate # 요청일 조회가 완전 실패(타임아웃 등)하면 lookback 으로 오염 캐시하지 않음. # 주말·미래만: 디스크/메모리에 있는 직전일 재사용. 추가 HTTP lookback 은 # FX_USD_KRW_HTTP_LOOKBACK=1 일 때만 (기본 OFF — 타임아웃 폭주·오캐시 방지). use_http_lb = str(get_env_from_db("FX_USD_KRW_HTTP_LOOKBACK", "0") or "0").strip().lower() in ( "1", "true", "yes", "y", "on", ) max_back = int(get_env_int("FX_USD_KRW_LOOKBACK_DAYS", 5)) try: dt = datetime.strptime(ymd, "%Y-%m-%d").date() except ValueError: return None for i in range(1, max_back + 1): prev = (dt - timedelta(days=i)).isoformat() with _cache_lock: if prev in _mem_rates and _mem_rates[prev] > 0: rate = _mem_rates[prev] _write_disk(ymd, rate, source_date=prev) _mem_rates[ymd] = rate return rate disk_prev = _read_disk(prev) if disk_prev is not None and disk_prev > 0: with _cache_lock: _mem_rates[prev] = disk_prev _mem_rates[ymd] = disk_prev _write_disk(ymd, disk_prev, source_date=prev) return disk_prev if not use_http_lb: continue detail = cli.fetch_rate_detail(prev) if detail is None: continue rate, src = detail if rate > 0: _write_disk(prev, rate, source_date=src) _write_disk(ymd, rate, source_date=src) with _cache_lock: _mem_rates[prev] = rate _mem_rates[ymd] = rate return rate return None def get_usd_krw_rates(dates: Iterable[str]) -> Dict[str, float]: """여러 일자 → {YYYY-MM-DD: rate}. 실패일은 fallback 사용하지 않고 생략.""" out: Dict[str, float] = {} uniq: List[str] = [] seen: Set[str] = set() for d in dates: y = _norm_ymd(d) if not y or y in seen: continue seen.add(y) uniq.append(y) if not uniq: return out cli = UsdKrwFxClient() for y in uniq: r = get_usd_krw_rate(y, client=cli) if r is not None and r > 0: out[y] = round(float(r), 4) return out def enrich_payload_with_usd_krw_fx(payload: Dict[str, Any]) -> Dict[str, Any]: """ 백테/실거래/페이퍼 JSON 에 currency·fx_rates·거래별 fx_rate/pnl_krw 부착. 기존 숫자(USD)는 변경하지 않음 — 표시용 필드만 추가. """ if not isinstance(payload, dict): return payload trades = payload.get("trades") or [] if not isinstance(trades, list): trades = [] need: List[str] = [] for t in trades: if isinstance(t, dict): y = _trade_fx_ymd(t) if y: need.append(y) # 요약용: end / 마지막 청산일 params = payload.get("params") if isinstance(payload.get("params"), dict) else {} summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {} for k in ("end", "start"): y = _norm_ymd(params.get(k) if params else None) if y: need.append(y) rates = get_usd_krw_rates(need) fb = _fallback_rate() total_pnl_krw = 0.0 used_fallback = False for t in trades: if not isinstance(t, dict): continue y = _trade_fx_ymd(t) rate = rates.get(y) if y else None if rate is None or rate <= 0: rate = fb used_fallback = True t["fx_ymd"] = y or None t["fx_rate"] = round(float(rate), 4) try: pnl = float(t.get("pnl") if t.get("pnl") is not None else (t.get("realized_pnl") or t.get("unrealized_pnl") or 0)) except (TypeError, ValueError): pnl = 0.0 pnl_krw = round(pnl * float(rate)) t["pnl_krw"] = pnl_krw if not t.get("is_open"): total_pnl_krw += pnl_krw # 가격 원 환산(참고) for src, dst in ( ("buy_price", "buy_price_krw"), ("avg_price", "avg_price_krw"), ("entry", "entry_krw"), ("sell_price", "sell_price_krw"), ("exit_price", "exit_price_krw"), ("exit", "exit_krw"), ): if t.get(src) is None: continue try: t[dst] = round(float(t.get(src)) * float(rate)) except (TypeError, ValueError): pass payload["currency"] = "USD" payload["fx_pair"] = "USD/KRW" payload["fx_rates"] = rates payload["fx_source"] = ( "frankfurter" if (_http_enabled() and _api_base()) else "cache_or_fallback" ) payload["fx_fallback_used"] = used_fallback if summary is not None: try: usd_pnl = float(summary.get("total_pnl") or 0) except (TypeError, ValueError): usd_pnl = 0.0 # 거래별 합이 있으면 그걸 우선(당일 환율 반영) if trades: summary["total_pnl_krw"] = int(round(total_pnl_krw)) else: end_y = _norm_ymd(params.get("end") if params else None) er = rates.get(end_y) if end_y else None if er is None or er <= 0: er = fb used_fallback = True summary["total_pnl_krw"] = int(round(usd_pnl * float(er))) try: mdd = float(summary.get("max_drawdown") or 0) except (TypeError, ValueError): mdd = 0.0 end_y = _norm_ymd(params.get("end") if params else None) or (max(rates.keys()) if rates else "") er = rates.get(end_y) if end_y else None if er is None or er <= 0: er = fb summary["max_drawdown_krw"] = int(round(mdd * float(er))) summary["fx_rate_end"] = round(float(er), 4) if er else None payload["summary"] = summary payload["fx_fallback_used"] = used_fallback or bool(payload.get("fx_fallback_used")) return payload