411 lines
14 KiB
Python
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
|