396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""
|
|
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):
|
|
"""
|
|
Fetch today's open-to-current day change for a ticker.
|
|
|
|
Strategy (in order):
|
|
1. meta.regularMarketOpen + meta.regularMarketPrice (most accurate)
|
|
2. Last bar open[] + last bar close[] from the OHLC array (fallback)
|
|
|
|
Uses range=5d so the API always returns data even on weekends / holidays
|
|
when range=1d would return an empty result set.
|
|
|
|
Returns dict: {ticker, open, current, day_change, day_change_pct}
|
|
or None on complete 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=10)
|
|
if resp.status_code != 200:
|
|
log.warning('[investment] %s day-change: HTTP %s (%s)', ticker, resp.status_code, subdomain)
|
|
continue
|
|
|
|
chart_data = resp.json().get('chart', {})
|
|
if chart_data.get('error'):
|
|
log.warning('[investment] %s day-change: API error %s', ticker, chart_data['error'])
|
|
continue
|
|
|
|
result = chart_data.get('result')
|
|
if not result:
|
|
log.warning('[investment] %s day-change: empty result (%s)', ticker, subdomain)
|
|
continue
|
|
|
|
meta = result[0].get('meta', {})
|
|
|
|
# Strategy 1: meta fields (true intraday open → current)
|
|
open_p, curr, chg, chg_pct = _extract_day_change_from_meta(meta)
|
|
|
|
# Strategy 2: fall back to last OHLC bar open/close
|
|
if open_p is None or curr is None:
|
|
try:
|
|
quote = result[0]['indicators']['quote'][0]
|
|
valid_opens = [v for v in quote.get('open', []) if v is not None]
|
|
valid_closes = [v for v in quote.get('close', []) if v is not None]
|
|
if valid_opens and valid_closes:
|
|
open_p = float(valid_opens[-1])
|
|
curr = float(valid_closes[-1])
|
|
chg = round(curr - open_p, 4)
|
|
chg_pct = round(chg / open_p * 100, 2) if open_p != 0 else 0
|
|
log.info('[investment] %s day-change: using OHLC fallback', ticker)
|
|
except (KeyError, IndexError, TypeError) as exc:
|
|
log.warning('[investment] %s day-change: OHLC fallback failed: %s', ticker, exc)
|
|
|
|
if open_p is None or curr is None:
|
|
log.warning('[investment] %s day-change: no open/current available (meta keys: %s)',
|
|
ticker, list(meta.keys())[:10])
|
|
continue
|
|
|
|
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)
|
|
|
|
log.error('[investment] %s: day-change fetch failed on all subdomains', ticker)
|
|
|
|
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.
|
|
Also returns per-account groups for investments that are linked to an account
|
|
(e.g. separate Schwab Individual vs Roth IRA views).
|
|
"""
|
|
from sqlalchemy import func
|
|
investments = Investment.query.filter_by(is_active=True).order_by(
|
|
func.isnull(Investment.account_id), # 0 for linked, 1 for NULL → linked first
|
|
Investment.account_id,
|
|
Investment.asset_type,
|
|
Investment.asset_name,
|
|
).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'),
|
|
})
|
|
|
|
# Build per-account groups (only for investments with account_id set)
|
|
account_groups = {} # account_id (or None) → {'account': Account|None, 'investments': [...]}
|
|
for inv in investments:
|
|
key = inv.account_id
|
|
if key not in account_groups:
|
|
account_groups[key] = {
|
|
'account': inv.account, # Account object or None
|
|
'investments': [],
|
|
'total_value': 0,
|
|
'total_cost': 0,
|
|
}
|
|
account_groups[key]['investments'].append(inv)
|
|
account_groups[key]['total_value'] += inv.current_value
|
|
account_groups[key]['total_cost'] += inv.total_cost
|
|
|
|
# Sort: named accounts first (sorted by name), then unlinked holdings last
|
|
sorted_groups = sorted(
|
|
account_groups.values(),
|
|
key=lambda g: (g['account'] is None, g['account'].name if g['account'] else ''),
|
|
)
|
|
|
|
# Only return groups if there's more than one distinct account present
|
|
multi_account = len([g for g in sorted_groups if g['account'] is not None]) > 1 or \
|
|
(len(sorted_groups) > 1)
|
|
|
|
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),
|
|
'account_groups': sorted_groups if multi_account else [],
|
|
}
|