05/31 Fix yfinance issue
This commit is contained in:
+20
-11
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user