""" FX Service — fetches and caches daily USD→VND exchange rate. Source priority: 1. DB cache (same day, fresh) 2. yfinance (USDVND=X forex ticker — most reliable) 3. open.er-api.com (free REST API fallback) 4. Last known DB rate (stale fallback) Force-refresh: if cached rate is > 1 day old, re-fetch regardless. """ import logging import requests from datetime import date, datetime, timedelta from app.extensions import db from app.models.fx_rate import FxRate log = logging.getLogger(__name__) REQUEST_TIMEOUT = 10 def get_today_rate(): """ Return today's USD→VND rate as a dict: { 'rate': 26350.00, 'date': date(...), 'source': '...', 'is_stale': False } """ today = date.today() # 1. Check DB cache — only use if fetched TODAY cached = FxRate.query.filter_by(date=today).first() if cached: return { 'rate': float(cached.usd_to_vnd), 'date': cached.date, 'source': cached.source, 'is_stale': False, } # 2. Try all live sources in order fetchers = [ _fetch_yfinance, _fetch_er_api, ] for fetcher in fetchers: rate = fetcher() if rate and rate > 1000: # sanity check: VND is always >> 1000 per USD _save_rate(today, rate, fetcher.__name__.replace('_fetch_', '')) return {'rate': rate, 'date': today, 'source': fetcher.__name__, 'is_stale': False} # 3. Stale fallback — last known rate from DB last = FxRate.query.order_by(FxRate.date.desc()).first() if last: days_old = (today - last.date).days log.warning(f'[fx] Using stale rate from {last.date} ({days_old}d old)') return { 'rate': float(last.usd_to_vnd), 'date': last.date, 'source': last.source, 'is_stale': True, } return None def force_refresh(): """Delete today's cached rate and fetch fresh. Returns new rate dict.""" today = date.today() existing = FxRate.query.filter_by(date=today).first() if existing: db.session.delete(existing) db.session.commit() return get_today_rate() # ── Fetchers ────────────────────────────────────────────────────────────────── def _fetch_yfinance(): """Fetch USDVND=X via Yahoo Finance v8 chart API (direct HTTP, no yfinance needed).""" for subdomain in ('query1', 'query2'): url = ( f'https://{subdomain}.finance.yahoo.com/v8/finance/chart/USDVND=X' '?range=5d&interval=1d&includePrePost=false' ) try: resp = requests.get(url, timeout=REQUEST_TIMEOUT, headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}) if resp.status_code == 200: result = resp.json().get('chart', {}).get('result') if result: closes = result[0]['indicators']['quote'][0]['close'] valid = [c for c in closes if c is not None] if valid: rate = float(valid[-1]) log.info(f'[fx] Yahoo v8: 1 USD = {rate:,.0f} VND') return rate except Exception as e: log.warning(f'[fx] Yahoo v8 {subdomain}: {e}') return None def _fetch_er_api(): """Fetch from open.er-api.com (free, no key required).""" try: resp = requests.get( 'https://open.er-api.com/v6/latest/USD', timeout=REQUEST_TIMEOUT, headers={'User-Agent': 'PFM/1.0'}, ) if resp.status_code == 200: data = resp.json() vnd = data.get('rates', {}).get('VND') if vnd and float(vnd) > 1000: log.info(f'[fx] er-api: 1 USD = {vnd:,.0f} VND') return float(vnd) except Exception as e: log.warning(f'[fx] er-api failed: {e}') return None # ── DB helpers ──────────────────────────────────────────────────────────────── def _save_rate(rate_date, rate, source): try: # Upsert — delete existing for this date first existing = FxRate.query.filter_by(date=rate_date).first() if existing: db.session.delete(existing) db.session.flush() record = FxRate( date=rate_date, usd_to_vnd=rate, source=source, fetched_at=datetime.utcnow(), ) db.session.add(record) db.session.commit() except Exception as e: db.session.rollback() log.error(f'[fx] DB save failed: {e}') def get_rate_history(days=30): """Return list of FxRate records for last N days, oldest first.""" since = date.today() - timedelta(days=days) return (FxRate.query .filter(FxRate.date >= since) .order_by(FxRate.date.asc()) .all())