05/31 Phase 7: fixed fetch fx rate
This commit is contained in:
+16
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,8 +116,15 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pcard" style="background:#1e293b;border-color:#334155;">
|
||||
<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 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 %}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user