143 lines
4.5 KiB
Python
143 lines
4.5 KiB
Python
"""
|
|
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 yfinance. Most reliable — uses Yahoo Finance forex."""
|
|
try:
|
|
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 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())
|