05/31 Fix yfinance issue

This commit is contained in:
2026-05-31 21:57:44 -04:00
parent 04dc35388a
commit 9bc8bb3238
3 changed files with 115 additions and 57 deletions
+20 -11
View File
@@ -77,17 +77,26 @@ def force_refresh():
# ── Fetchers ────────────────────────────────────────────────────────────────── # ── Fetchers ──────────────────────────────────────────────────────────────────
def _fetch_yfinance(): def _fetch_yfinance():
"""Fetch USDVND=X via yfinance. Most reliable — uses Yahoo Finance forex.""" """Fetch USDVND=X via Yahoo Finance v8 chart API (direct HTTP, no yfinance needed)."""
try: for subdomain in ('query1', 'query2'):
import yfinance as yf url = (
ticker = yf.Ticker('USDVND=X') f'https://{subdomain}.finance.yahoo.com/v8/finance/chart/USDVND=X'
hist = ticker.history(period='5d') '?range=5d&interval=1d&includePrePost=false'
if not hist.empty: )
rate = float(hist['Close'].iloc[-1]) try:
log.info(f'[fx] yfinance: 1 USD = {rate:,.0f} VND') resp = requests.get(url, timeout=REQUEST_TIMEOUT,
return rate headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'})
except Exception as e: if resp.status_code == 200:
log.warning(f'[fx] yfinance failed: {e}') result = resp.json().get('chart', {}).get('result')
if result:
closes = result[0]['indicators']['quote'][0]['close']
valid = [c for c in closes if c is not None]
if valid:
rate = float(valid[-1])
log.info(f'[fx] Yahoo v8: 1 USD = {rate:,.0f} VND')
return rate
except Exception as e:
log.warning(f'[fx] Yahoo v8 {subdomain}: {e}')
return None return None
+94 -45
View File
@@ -1,31 +1,104 @@
""" """
Investment Service — price fetching via yfinance, portfolio calculations. Investment Service — price fetching and portfolio calculations.
Yahoo Finance v8 chart API (direct HTTP, no yfinance dependency).
Endpoint: https://query1.finance.yahoo.com/v8/finance/chart/{ticker}?range=2d&interval=1d
No API key needed. Uses a browser User-Agent header.
Falls back to query2 subdomain if query1 fails.
""" """
import logging import logging
import requests
from datetime import datetime from datetime import datetime
from app.extensions import db from app.extensions import db
from app.models.investment import Investment from app.models.investment import Investment
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
REQUEST_TIMEOUT = 10
HEADERS = {
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
),
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
}
# Consistent colors per asset type
ASSET_COLORS = {
'stock': '#3b82f6',
'etf': '#06b6d4',
'crypto': '#f59e0b',
'real_estate': '#10b981',
'bond': '#8b5cf6',
'cash': '#64748b',
'other': '#ec4899',
}
ASSET_TYPE_LABELS = {
'stock': 'Stock',
'etf': 'ETF',
'crypto': 'Crypto',
'real_estate': 'Real Estate',
'bond': 'Bond',
'cash': 'Cash',
'other': 'Other',
}
def fetch_price(ticker): def fetch_price(ticker):
""" """
Fetch latest price for a ticker via yfinance. Fetch latest closing price for a ticker via Yahoo Finance v8 chart API.
Tries query1 then query2 subdomain as fallback.
Returns float or None on failure. Returns float or None on failure.
""" """
if not ticker: if not ticker:
return None return None
ticker = ticker.upper().strip()
for subdomain in ('query1', 'query2'):
url = (
f'https://{subdomain}.finance.yahoo.com/v8/finance/chart/{ticker}'
f'?range=5d&interval=1d&includePrePost=false'
)
try:
resp = requests.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT)
if resp.status_code == 200:
price = _parse_v8_price(resp.json())
if price is not None:
log.info(f'[investment] {ticker}: {price:.4f} via {subdomain}')
return price
elif resp.status_code == 404:
log.warning(f'[investment] {ticker}: not found on Yahoo Finance')
return None
else:
log.warning(f'[investment] {ticker} {subdomain}: HTTP {resp.status_code}')
except requests.exceptions.Timeout:
log.warning(f'[investment] {ticker} {subdomain}: timeout')
except Exception as e:
log.warning(f'[investment] {ticker} {subdomain}: {e}')
log.error(f'[investment] {ticker}: all sources failed')
return None
def _parse_v8_price(data):
"""Extract the most recent closing price from a v8 chart API response."""
try: try:
import yfinance as yf result = data['chart']['result']
t = yf.Ticker(ticker.upper()) if not result:
hist = t.history(period='2d')
if hist.empty:
return None return None
return float(hist['Close'].iloc[-1]) closes = result[0]['indicators']['quote'][0]['close']
except Exception as e: # Filter out None values (market closed / missing data)
log.warning(f'[investment] price fetch failed for {ticker}: {e}') valid = [c for c in closes if c is not None]
if not valid:
return None
return float(valid[-1])
except (KeyError, IndexError, TypeError) as e:
log.warning(f'[investment] v8 parse error: {e}')
return None return None
@@ -63,14 +136,12 @@ def update_prices(investment_ids=None):
def get_portfolio_summary(): def get_portfolio_summary():
""" """Return portfolio-level aggregates across all active investments."""
Return portfolio-level aggregates across all active investments.
"""
investments = Investment.query.filter_by(is_active=True).all() investments = Investment.query.filter_by(is_active=True).all()
total_cost = sum(i.total_cost for i in investments) total_cost = sum(i.total_cost for i in investments)
total_value = sum(i.current_value for i in investments) total_value = sum(i.current_value for i in investments)
total_gain = total_value - total_cost total_gain = total_value - total_cost
total_gain_pct = round((total_gain / total_cost) * 100, 2) if total_cost > 0 else 0 total_gain_pct = round((total_gain / total_cost) * 100, 2) if total_cost > 0 else 0
# Group by asset type for allocation chart # Group by asset type for allocation chart
@@ -83,40 +154,18 @@ def get_portfolio_summary():
for asset_type, value in sorted(type_totals.items(), key=lambda x: -x[1]): for asset_type, value in sorted(type_totals.items(), key=lambda x: -x[1]):
pct = round((value / total_value * 100), 1) if total_value > 0 else 0 pct = round((value / total_value * 100), 1) if total_value > 0 else 0
allocation.append({ allocation.append({
'type': asset_type, 'type': asset_type,
'value': value, 'value': value,
'pct': pct, 'pct': pct,
'color': ASSET_COLORS.get(asset_type, '#94a3b8'), 'color': ASSET_COLORS.get(asset_type, '#94a3b8'),
}) })
return { return {
'investments': investments, 'investments': investments,
'total_cost': total_cost, 'total_cost': total_cost,
'total_value': total_value, 'total_value': total_value,
'total_gain': total_gain, 'total_gain': total_gain,
'total_gain_pct': total_gain_pct, 'total_gain_pct': total_gain_pct,
'allocation': allocation, 'allocation': allocation,
'count': len(investments), 'count': len(investments),
} }
# Consistent colors per asset type
ASSET_COLORS = {
'stock': '#3b82f6',
'etf': '#06b6d4',
'crypto': '#f59e0b',
'real_estate': '#10b981',
'bond': '#8b5cf6',
'cash': '#64748b',
'other': '#ec4899',
}
ASSET_TYPE_LABELS = {
'stock': 'Stock',
'etf': 'ETF',
'crypto': 'Crypto',
'real_estate': 'Real Estate',
'bond': 'Bond',
'cash': 'Cash',
'other': 'Other',
}
+1 -1
View File
@@ -7,7 +7,7 @@ pymysql==1.1.1
python-dotenv==1.0.1 python-dotenv==1.0.1
gunicorn==23.0.0 gunicorn==23.0.0
groq==0.13.1 groq==0.13.1
yfinance==0.2.54 # yfinance removed — using Yahoo Finance v8 API directly (more reliable)
weasyprint==63.1 weasyprint==63.1
openpyxl==3.1.5 openpyxl==3.1.5
Pillow==11.1.0 Pillow==11.1.0