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.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
+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)
+42 -8
View File
@@ -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 @@
<div class="col-12 col-xl-4 d-flex flex-column gap-3">
<!-- USD/VND Widget -->
{% if fx %}
<div class="pcard fx-card" onclick="toggleFxChart()">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="pcard fx-card" id="fxWidget">
<div class="d-flex justify-content-between align-items-start">
<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 class="mono" 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;">
1 USD · {{ fx.date.strftime('%b %d, %Y') }}
{% if fx.is_stale %}<span title="Rate may be outdated">⚠️</span>{% endif %}
<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;" id="fxMeta">
1 USD · <span id="fxDate">{{ fx.date.strftime('%b %d, %Y') }}</span>
· <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 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 id="fxChartWrap" style="display:none;margin-top:12px;">
<div style="height:70px;"><canvas id="fxChart"></canvas></div>
@@ -111,9 +116,16 @@
</div>
{% else %}
<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:13px;color:#94a3b8;margin-top:6px;">Rate unavailable</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 %}
<!-- Top Spending -->
@@ -285,6 +297,28 @@
})();
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(){
const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
+9 -7
View File
@@ -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)