Files
kis_bot/kis_trader/utils/usd_krw_fx.py
2026-07-30 18:05:07 +09:00

411 lines
14 KiB
Python

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