diff --git a/app/services/fx_service.py b/app/services/fx_service.py index 9dcbcaf..85744a2 100644 --- a/app/services/fx_service.py +++ b/app/services/fx_service.py @@ -77,17 +77,26 @@ def force_refresh(): # ── Fetchers ────────────────────────────────────────────────────────────────── def _fetch_yfinance(): - """Fetch USDVND=X via yfinance. Most reliable — uses Yahoo Finance forex.""" - try: - 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}') + """Fetch USDVND=X via Yahoo Finance v8 chart API (direct HTTP, no yfinance needed).""" + for subdomain in ('query1', 'query2'): + url = ( + f'https://{subdomain}.finance.yahoo.com/v8/finance/chart/USDVND=X' + '?range=5d&interval=1d&includePrePost=false' + ) + try: + resp = requests.get(url, timeout=REQUEST_TIMEOUT, + headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}) + if resp.status_code == 200: + 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 diff --git a/app/services/investment_service.py b/app/services/investment_service.py index 203d8d2..6c9cc89 100644 --- a/app/services/investment_service.py +++ b/app/services/investment_service.py @@ -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 requests from datetime import datetime from app.extensions import db from app.models.investment import Investment 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): """ - 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. """ if not ticker: 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: - import yfinance as yf - t = yf.Ticker(ticker.upper()) - hist = t.history(period='2d') - if hist.empty: + result = data['chart']['result'] + if not result: return None - return float(hist['Close'].iloc[-1]) - except Exception as e: - log.warning(f'[investment] price fetch failed for {ticker}: {e}') + closes = result[0]['indicators']['quote'][0]['close'] + # Filter out None values (market closed / missing data) + 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 @@ -63,14 +136,12 @@ def update_prices(investment_ids=None): 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() - 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_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 # 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]): pct = round((value / total_value * 100), 1) if total_value > 0 else 0 allocation.append({ - 'type': asset_type, + 'type': asset_type, 'value': value, - 'pct': pct, + 'pct': pct, 'color': ASSET_COLORS.get(asset_type, '#94a3b8'), }) return { - 'investments': investments, - 'total_cost': total_cost, - 'total_value': total_value, - 'total_gain': total_gain, - 'total_gain_pct': total_gain_pct, - 'allocation': allocation, - 'count': len(investments), + 'investments': investments, + 'total_cost': total_cost, + 'total_value': total_value, + 'total_gain': total_gain, + 'total_gain_pct': total_gain_pct, + 'allocation': allocation, + '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', -} diff --git a/requirements.txt b/requirements.txt index 28125c5..279ddc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ pymysql==1.1.1 python-dotenv==1.0.1 gunicorn==23.0.0 groq==0.13.1 -yfinance==0.2.54 +# yfinance removed — using Yahoo Finance v8 API directly (more reliable) weasyprint==63.1 openpyxl==3.1.5 Pillow==11.1.0