05/31 Phase 7: fixed fetch fx rate

This commit is contained in:
2026-05-31 17:10:09 -04:00
parent 84f48a7b4d
commit d74b9e7543
4 changed files with 159 additions and 52 deletions
+90 -34
View File
@@ -1,27 +1,33 @@
"""
FX Service — fetches and caches daily USD→VND exchange rate.
Primary source: open.er-api.com (free, no key)
Fallback: last known rate from DB
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
from datetime import date, datetime, timedelta
from app.extensions import db
from app.models.fx_rate import FxRate
ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
REQUEST_TIMEOUT = 8 # seconds
log = logging.getLogger(__name__)
REQUEST_TIMEOUT = 10
def get_today_rate():
"""
Return today's USD→VND rate as a dict:
{ 'rate': 25450.00, 'date': date(...), 'source': '...', 'is_stale': False }
{ 'rate': 26350.00, 'date': date(...), 'source': '...', 'is_stale': False }
"""
today = date.today()
# 1. Check cache
# 1. Check DB cache — only use if fetched TODAY
cached = FxRate.query.filter_by(date=today).first()
if cached:
return {
@@ -31,26 +37,23 @@ def get_today_rate():
'is_stale': False,
}
# 2. Fetch from API
rate, source = _fetch_from_api()
# 2. Try all live sources in order
fetchers = [
_fetch_yfinance,
_fetch_er_api,
]
if rate:
record = FxRate(
date=today,
usd_to_vnd=rate,
source=source,
fetched_at=datetime.utcnow(),
)
db.session.add(record)
try:
db.session.commit()
except Exception:
db.session.rollback()
return {'rate': rate, 'date': today, 'source': source, 'is_stale': False}
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. Fallback — last known rate
# 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,
@@ -58,27 +61,80 @@ def get_today_rate():
'is_stale': True,
}
# 4. Nothing available
return None
def _fetch_from_api():
"""Try open.er-api.com. Returns (rate, source) or (None, 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 yfinance. Most reliable — uses Yahoo Finance forex."""
try:
resp = requests.get(ER_API_URL, timeout=REQUEST_TIMEOUT)
import yfinance as yf
ticker = yf.Ticker('USDVND=X')
hist = ticker.history(period='5d')
if not hist.empty:
rate = float(hist['Close'].iloc[-1])
log.info(f'[fx] yfinance: 1 USD = {rate:,.0f} VND')
return rate
except Exception as e:
log.warning(f'[fx] yfinance failed: {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:
return float(vnd), 'exchangerate-api'
except Exception:
pass
return None, None
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."""
from datetime import timedelta
since = date.today() - timedelta(days=days)
return (FxRate.query
.filter(FxRate.date >= since)