diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 7b80bad..d796150 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -5,7 +5,7 @@ from app.extensions import db from app.models.account import Account from app.models.transaction import Transaction from app.models.category import Category -from app.services.fx_service import get_today_rate, get_rate_history +from app.services.fx_service import get_today_rate, get_rate_history, force_refresh from app.services.ai_service import get_latest_daily_insight from app.services.account_service import get_total_assets, get_total_liabilities from datetime import date, datetime, timedelta @@ -161,3 +161,18 @@ def fx_history_api(): 'dates': [r.date.strftime('%b %d') for r in history], 'rates': [float(r.usd_to_vnd) for r in history], }) + + +@dashboard_bp.route('/api/fx-refresh', methods=['POST']) +@login_required +def fx_refresh(): + """Force-refresh today's USD/VND rate.""" + result = force_refresh() + if result: + return jsonify({ + 'rate': result['rate'], + 'date': result['date'].strftime('%b %d, %Y'), + 'source': result['source'], + 'is_stale': result['is_stale'], + }) + return jsonify({'error': 'Could not fetch rate'}), 503 diff --git a/app/services/fx_service.py b/app/services/fx_service.py index d16d2cd..9dcbcaf 100644 --- a/app/services/fx_service.py +++ b/app/services/fx_service.py @@ -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) diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index 0a8332b..4ac5edf 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -6,6 +6,7 @@ .fx-card { background: #0f172a; border-color: #1e293b; color: #f1f5f9; cursor: pointer; transition: all .2s; } .fx-card:hover { border-color: #3b82f6 !important; } .period-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } +@keyframes spin { to { transform: rotate(360deg); } } {% endblock %} {% block topbar_actions %} @@ -93,17 +94,21 @@