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
+16 -1
View File
@@ -5,7 +5,7 @@ from app.extensions import db
from app.models.account import Account from app.models.account import Account
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.category import Category 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.ai_service import get_latest_daily_insight
from app.services.account_service import get_total_assets, get_total_liabilities from app.services.account_service import get_total_assets, get_total_liabilities
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -161,3 +161,18 @@ def fx_history_api():
'dates': [r.date.strftime('%b %d') for r in history], 'dates': [r.date.strftime('%b %d') for r in history],
'rates': [float(r.usd_to_vnd) 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
+90 -34
View File
@@ -1,27 +1,33 @@
""" """
FX Service — fetches and caches daily USD→VND exchange rate. 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 import requests
from datetime import date, datetime from datetime import date, datetime, timedelta
from app.extensions import db from app.extensions import db
from app.models.fx_rate import FxRate from app.models.fx_rate import FxRate
log = logging.getLogger(__name__)
ER_API_URL = 'https://open.er-api.com/v6/latest/USD' REQUEST_TIMEOUT = 10
REQUEST_TIMEOUT = 8 # seconds
def get_today_rate(): def get_today_rate():
""" """
Return today's USD→VND rate as a dict: 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() today = date.today()
# 1. Check cache # 1. Check DB cache — only use if fetched TODAY
cached = FxRate.query.filter_by(date=today).first() cached = FxRate.query.filter_by(date=today).first()
if cached: if cached:
return { return {
@@ -31,26 +37,23 @@ def get_today_rate():
'is_stale': False, 'is_stale': False,
} }
# 2. Fetch from API # 2. Try all live sources in order
rate, source = _fetch_from_api() fetchers = [
_fetch_yfinance,
_fetch_er_api,
]
if rate: for fetcher in fetchers:
record = FxRate( rate = fetcher()
date=today, if rate and rate > 1000: # sanity check: VND is always >> 1000 per USD
usd_to_vnd=rate, _save_rate(today, rate, fetcher.__name__.replace('_fetch_', ''))
source=source, return {'rate': rate, 'date': today, 'source': fetcher.__name__, 'is_stale': False}
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}
# 3. Fallback — last known rate # 3. Stale fallback — last known rate from DB
last = FxRate.query.order_by(FxRate.date.desc()).first() last = FxRate.query.order_by(FxRate.date.desc()).first()
if last: if last:
days_old = (today - last.date).days
log.warning(f'[fx] Using stale rate from {last.date} ({days_old}d old)')
return { return {
'rate': float(last.usd_to_vnd), 'rate': float(last.usd_to_vnd),
'date': last.date, 'date': last.date,
@@ -58,27 +61,80 @@ def get_today_rate():
'is_stale': True, 'is_stale': True,
} }
# 4. Nothing available
return None return None
def _fetch_from_api(): def force_refresh():
"""Try open.er-api.com. Returns (rate, source) or (None, None).""" """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: 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: if resp.status_code == 200:
data = resp.json() data = resp.json()
vnd = data.get('rates', {}).get('VND') vnd = data.get('rates', {}).get('VND')
if vnd: if vnd and float(vnd) > 1000:
return float(vnd), 'exchangerate-api' log.info(f'[fx] er-api: 1 USD = {vnd:,.0f} VND')
except Exception: return float(vnd)
pass except Exception as e:
return None, None 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): def get_rate_history(days=30):
"""Return list of FxRate records for last N days, oldest first.""" """Return list of FxRate records for last N days, oldest first."""
from datetime import timedelta
since = date.today() - timedelta(days=days) since = date.today() - timedelta(days=days)
return (FxRate.query return (FxRate.query
.filter(FxRate.date >= since) .filter(FxRate.date >= since)
+42 -8
View File
@@ -6,6 +6,7 @@
.fx-card { background: #0f172a; border-color: #1e293b; color: #f1f5f9; cursor: pointer; transition: all .2s; } .fx-card { background: #0f172a; border-color: #1e293b; color: #f1f5f9; cursor: pointer; transition: all .2s; }
.fx-card:hover { border-color: #3b82f6 !important; } .fx-card:hover { border-color: #3b82f6 !important; }
.period-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } .period-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
@keyframes spin { to { transform: rotate(360deg); } }
{% endblock %} {% endblock %}
{% block topbar_actions %} {% block topbar_actions %}
@@ -93,17 +94,21 @@
<div class="col-12 col-xl-4 d-flex flex-column gap-3"> <div class="col-12 col-xl-4 d-flex flex-column gap-3">
<!-- USD/VND Widget --> <!-- USD/VND Widget -->
{% if fx %} {% if fx %}
<div class="pcard fx-card" onclick="toggleFxChart()"> <div class="pcard fx-card" id="fxWidget">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-start">
<div> <div onclick="toggleFxChart()" style="cursor:pointer;flex:1;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div> <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
<div class="mono" style="font-size:24px;font-weight:600;margin-top:4px;">₫{{ "{:,.0f}".format(fx.rate) }}</div> <div class="mono" id="fxRate" style="font-size:24px;font-weight:600;margin-top:4px;">₫{{ "{:,.0f}".format(fx.rate) }}</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;"> <div style="font-size:11px;color:#64748b;margin-top:2px;" id="fxMeta">
1 USD · {{ fx.date.strftime('%b %d, %Y') }} 1 USD · <span id="fxDate">{{ fx.date.strftime('%b %d, %Y') }}</span>
{% if fx.is_stale %}<span title="Rate may be outdated">⚠️</span>{% endif %} · <span id="fxSource" style="opacity:.6;">{{ fx.source }}</span>
{% if fx.is_stale %}<span id="fxStale" title="Rate may be outdated" style="color:#f59e0b;"> ⚠ stale</span>{% endif %}
</div> </div>
</div> </div>
<div style="font-size:32px;opacity:.15;font-family:serif;"></div> <button id="fxRefreshBtn" onclick="refreshFxRate()" title="Refresh rate"
style="background:none;border:none;color:#475569;cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;margin-top:2px;">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div> </div>
<div id="fxChartWrap" style="display:none;margin-top:12px;"> <div id="fxChartWrap" style="display:none;margin-top:12px;">
<div style="height:70px;"><canvas id="fxChart"></canvas></div> <div style="height:70px;"><canvas id="fxChart"></canvas></div>
@@ -111,9 +116,16 @@
</div> </div>
{% else %} {% else %}
<div class="pcard" style="background:#1e293b;border-color:#334155;"> <div class="pcard" style="background:#1e293b;border-color:#334155;">
<div class="d-flex justify-content-between align-items-center">
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div> <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
<div style="font-size:13px;color:#94a3b8;margin-top:6px;">Rate unavailable</div> <div style="font-size:13px;color:#94a3b8;margin-top:6px;">Rate unavailable</div>
</div> </div>
<button onclick="refreshFxRate()" style="background:none;border:none;color:#475569;cursor:pointer;font-size:14px;">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
{% endif %} {% endif %}
<!-- Top Spending --> <!-- Top Spending -->
@@ -285,6 +297,28 @@
})(); })();
var fxChart = null; var fxChart = null;
function refreshFxRate() {
const btn = document.getElementById('fxRefreshBtn');
if (btn) { btn.innerHTML = '<i class="bi bi-arrow-clockwise" style="animation:spin .7s linear infinite;display:inline-block;"></i>'; }
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 = '<i class="bi bi-arrow-clockwise"></i>';
})
.catch(() => { if (btn) btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>'; });
}
function toggleFxChart(){ function toggleFxChart(){
const wrap = document.getElementById('fxChartWrap'); const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none'; wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
+9 -7
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/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. Run by systemd timer pfm-fxrate.timer at 8AM daily.
Always force-refreshes regardless of cache.
""" """
import sys import sys
@@ -9,17 +10,18 @@ import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app 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() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
with app.app_context(): with app.app_context():
result = get_today_rate() print('[fx_rate] Fetching fresh USD/VND rate...')
result = force_refresh()
if result: if result:
status = '(stale)' if result.get('is_stale') else '' status = '(stale fallback)' if result.get('is_stale') else ''
print(f"[fx_rate] 1 USD = {result['rate']:,.0f} VND " print(f'[fx_rate] 1 USD = {result["rate"]:,.0f} VND '
f"[{result['source']}] {result['date']} {status}") f'[{result["source"]}] {result["date"]} {status}')
else: else:
print("[fx_rate] Failed to fetch rate.") print('[fx_rate] Failed to fetch rate from all sources.')
sys.exit(1) sys.exit(1)