""" 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 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: result = data['chart']['result'] if not result: return None 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 TIMEFRAME_MAP = { '1W': ('5d', '1d'), '1M': ('1mo', '1d'), '3M': ('3mo', '1d'), '6M': ('6mo', '1wk'), '1Y': ('1y', '1wk'), } def _extract_day_change_from_meta(meta): """ Extract open-to-current day change from a Yahoo Finance v8 meta block. Returns (open_price, current_price, day_change, day_change_pct) or (None,)*4. Yahoo Finance always includes regularMarketOpen (session open) and regularMarketPrice (latest trade), so this gives the true intraday move rather than the previous-close-to-latest approximation. """ try: open_price = float(meta['regularMarketOpen']) current_price = float(meta['regularMarketPrice']) day_change = round(current_price - open_price, 4) day_change_pct = round(day_change / open_price * 100, 2) if open_price != 0 else 0 return open_price, current_price, day_change, day_change_pct except (KeyError, TypeError, ValueError): return None, None, None, None def fetch_day_change(ticker): """ Lightweight call: fetch today's open price and current price only. Uses meta.regularMarketOpen / meta.regularMarketPrice from Yahoo Finance. Returns dict: {ticker, open, current, day_change, day_change_pct} 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=1d&interval=1d&includePrePost=false' ) try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: continue result = resp.json().get('chart', {}).get('result') if not result: return None meta = result[0].get('meta', {}) open_p, curr, chg, chg_pct = _extract_day_change_from_meta(meta) if open_p is None: return None log.info('[investment] %s day change: open=%.4f current=%.4f chg=%.4f (%.2f%%)', ticker, open_p, curr, chg, chg_pct) return { 'ticker': ticker, 'open': open_p, 'current': curr, 'day_change': chg, 'day_change_pct': chg_pct, } except Exception as exc: log.warning('[investment] %s day-change fetch failed (%s): %s', ticker, subdomain, exc) return None def fetch_price_history(ticker, timeframe='1M'): """ Fetch historical closing prices for a ticker via Yahoo Finance v8 API. timeframe: '1W' | '1M' | '3M' | '6M' | '1Y' day_change / day_change_pct reflect the true intraday move (regularMarketOpen → regularMarketPrice) from the response meta, not the close-to-close approximation. Returns dict: ticker, current, open_price, day_change, day_change_pct, period_change, period_change_pct, dates, closes, timeframe Returns None on failure. """ if not ticker: return None ticker = ticker.upper().strip() yf_range, yf_interval = TIMEFRAME_MAP.get(timeframe, ('1mo', '1d')) for subdomain in ('query1', 'query2'): url = ( f'https://{subdomain}.finance.yahoo.com/v8/finance/chart/{ticker}' f'?range={yf_range}&interval={yf_interval}&includePrePost=false' ) try: resp = requests.get(url, headers=HEADERS, timeout=15) if resp.status_code != 200: continue data = resp.json() result = data.get('chart', {}).get('result') if not result: return None meta = result[0].get('meta', {}) timestamps = result[0].get('timestamp', []) closes_raw = result[0]['indicators']['quote'][0].get('close', []) pairs = [(t, c) for t, c in zip(timestamps, closes_raw) if c is not None] if not pairs: return None dates = [datetime.utcfromtimestamp(t).strftime('%Y-%m-%d') for t, _ in pairs] closes = [round(float(c), 4) for _, c in pairs] # Use meta for accurate day change (open → current), fall back to # close-to-close only when meta fields are absent. open_p, curr, day_change, day_change_pct = _extract_day_change_from_meta(meta) if open_p is None: curr = closes[-1] prev = closes[-2] if len(closes) > 1 else curr day_change = round(curr - prev, 4) day_change_pct = round(day_change / prev * 100, 2) if prev != 0 else 0 open_p = prev current = curr if curr is not None else closes[-1] period_change = round(current - closes[0], 4) period_change_pct = round(period_change / closes[0] * 100, 2) if closes[0] != 0 else 0 log.info('[investment] %s history: %d points (%s) day_chg=%.2f%%', ticker, len(closes), timeframe, day_change_pct) return { 'ticker': ticker, 'current': current, 'open_price': open_p, 'day_change': day_change, 'day_change_pct': day_change_pct, 'period_change': period_change, 'period_change_pct': period_change_pct, 'dates': dates, 'closes': closes, 'timeframe': timeframe, } except Exception as exc: log.warning('[investment] %s history fetch failed (%s): %s', ticker, subdomain, exc) log.error('[investment] %s: history fetch failed on all subdomains', ticker) return None def update_prices(investment_ids=None): """ Update current_price for all (or specified) investments with a ticker. Returns dict: {ticker: new_price} """ query = Investment.query.filter( Investment.ticker != None, Investment.ticker != '', Investment.is_active == True, ) if investment_ids: query = query.filter(Investment.id.in_(investment_ids)) investments = query.all() updated = {} for inv in investments: price = fetch_price(inv.ticker) if price is not None: inv.current_price = price inv.last_price_update = datetime.utcnow() updated[inv.ticker] = price if updated: try: db.session.commit() except Exception as e: db.session.rollback() log.error(f'[investment] DB commit failed: {e}') return updated def get_portfolio_summary(): """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_value = sum(i.current_value for i in investments) 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 type_totals = {} for inv in investments: t = inv.asset_type type_totals[t] = type_totals.get(t, 0) + inv.current_value allocation = [] 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, 'value': value, '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), }