From d74b9e75434ae6900ef63658a71bf4f49cc2a2b6 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sun, 31 May 2026 17:10:09 -0400 Subject: [PATCH] 05/31 Phase 7: fixed fetch fx rate --- app/routes/dashboard.py | 17 +++- app/services/fx_service.py | 124 +++++++++++++++++++++-------- app/templates/dashboard/index.html | 54 ++++++++++--- scripts/fetch_fx_rate.py | 16 ++-- 4 files changed, 159 insertions(+), 52 deletions(-) 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 @@
{% if fx %} -
-
-
+
+
+
USD → VND
-
₫{{ "{:,.0f}".format(fx.rate) }}
-
- 1 USD · {{ fx.date.strftime('%b %d, %Y') }} - {% if fx.is_stale %}⚠️{% endif %} +
₫{{ "{:,.0f}".format(fx.rate) }}
+
+ 1 USD · {{ fx.date.strftime('%b %d, %Y') }} + · {{ fx.source }} + {% if fx.is_stale %} ⚠ stale{% endif %}
-
+
{% else %}
-
USD → VND
-
Rate unavailable
+
+
+
USD → VND
+
Rate unavailable
+
+ +
{% endif %} @@ -285,6 +297,28 @@ })(); var fxChart = null; + +function refreshFxRate() { + const btn = document.getElementById('fxRefreshBtn'); + if (btn) { btn.innerHTML = ''; } + fetch('/api/fx-refresh', { method:'POST', headers:{'Content-Type':'application/json','X-CSRFToken': csrfToken} }) + .then(r => r.json()) + .then(data => { + if (data.rate) { + const rateEl = document.getElementById('fxRate'); + const dateEl = document.getElementById('fxDate'); + const srcEl = document.getElementById('fxSource'); + const staleEl = document.getElementById('fxStale'); + if (rateEl) rateEl.textContent = '₫' + Math.round(data.rate).toLocaleString(); + if (dateEl) dateEl.textContent = data.date; + if (srcEl) srcEl.textContent = data.source; + if (staleEl) staleEl.style.display = data.is_stale ? '' : 'none'; + } + if (btn) btn.innerHTML = ''; + }) + .catch(() => { if (btn) btn.innerHTML = ''; }); +} + function toggleFxChart(){ const wrap = document.getElementById('fxChartWrap'); wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none'; diff --git a/scripts/fetch_fx_rate.py b/scripts/fetch_fx_rate.py index ee19d9c..3e38075 100644 --- a/scripts/fetch_fx_rate.py +++ b/scripts/fetch_fx_rate.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ -Cron script: fetch daily USD/VND exchange rate. +Cron script: fetch daily USD/VND exchange rate via yfinance. Run by systemd timer pfm-fxrate.timer at 8AM daily. +Always force-refreshes regardless of cache. """ import sys @@ -9,17 +10,18 @@ import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app import create_app -from app.services.fx_service import get_today_rate +from app.services.fx_service import force_refresh app = create_app() if __name__ == '__main__': with app.app_context(): - result = get_today_rate() + print('[fx_rate] Fetching fresh USD/VND rate...') + result = force_refresh() if result: - status = '(stale)' if result.get('is_stale') else '' - print(f"[fx_rate] 1 USD = {result['rate']:,.0f} VND " - f"[{result['source']}] {result['date']} {status}") + status = '(stale fallback)' if result.get('is_stale') else '' + print(f'[fx_rate] 1 USD = ₫{result["rate"]:,.0f} VND ' + f'[{result["source"]}] {result["date"]} {status}') else: - print("[fx_rate] Failed to fetch rate.") + print('[fx_rate] Failed to fetch rate from all sources.') sys.exit(1)