diff --git a/app/routes/investments.py b/app/routes/investments.py index b570a8b..e210936 100644 --- a/app/routes/investments.py +++ b/app/routes/investments.py @@ -6,7 +6,8 @@ from wtforms.validators import DataRequired, Optional, NumberRange, Length from app.extensions import db from app.models.investment import Investment, InvestmentTransaction from app.services.investment_service import ( - get_portfolio_summary, update_prices, fetch_price, fetch_price_history, + get_portfolio_summary, update_prices, fetch_price, + fetch_price_history, fetch_day_change, ASSET_COLORS, ASSET_TYPE_LABELS ) from datetime import date @@ -275,6 +276,19 @@ def api_price(ticker): }) +@investments_bp.route('/api/daychange/') +@login_required +def api_day_change(ticker): + """ + Lightweight endpoint: return today's open-to-current day change only. + Used by the portfolio page to load change badges quickly. + """ + data = fetch_day_change(ticker.upper().strip()) + if data is None: + return jsonify({'error': f'No data for {ticker}'}), 404 + return jsonify(data) + + @investments_bp.route('/api/history/') @login_required def api_price_history(ticker): diff --git a/app/services/investment_service.py b/app/services/investment_service.py index 59d6e76..fdd9dde 100644 --- a/app/services/investment_service.py +++ b/app/services/investment_service.py @@ -111,13 +111,81 @@ TIMEFRAME_MAP = { } +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, prev_close, day_change, day_change_pct, + ticker, current, open_price, day_change, day_change_pct, period_change, period_change_pct, dates, closes, timeframe Returns None on failure. """ @@ -141,6 +209,7 @@ def fetch_price_history(ticker, timeframe='1M'): 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] @@ -150,18 +219,26 @@ def fetch_price_history(ticker, timeframe='1M'): dates = [datetime.utcfromtimestamp(t).strftime('%Y-%m-%d') for t, _ in pairs] closes = [round(float(c), 4) for _, c in pairs] - current = closes[-1] - prev = closes[-2] if len(closes) > 1 else current - day_change = round(current - prev, 4) - day_change_pct = round(day_change / prev * 100, 2) if prev != 0 else 0 + # 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)', ticker, len(closes), timeframe) + log.info('[investment] %s history: %d points (%s) day_chg=%.2f%%', + ticker, len(closes), timeframe, day_change_pct) return { 'ticker': ticker, 'current': current, - 'prev_close': prev, + 'open_price': open_p, 'day_change': day_change, 'day_change_pct': day_change_pct, 'period_change': period_change, diff --git a/app/templates/investments/index.html b/app/templates/investments/index.html index dba2710..d0bafc4 100644 --- a/app/templates/investments/index.html +++ b/app/templates/investments/index.html @@ -315,10 +315,11 @@ {% endif %}{% endfor %} ]; - // Fetch all day-changes in parallel + // Fetch today's open→current day change for every ticker (parallel) + // Uses the lightweight /api/daychange/ endpoint (meta only, no history) Promise.all( tickerRows.map(r => - fetch(`{{ url_for('investments.api_price_history', ticker='__T__') }}`.replace('__T__', r.ticker) + '?tf=1W') + fetch(`{{ url_for('investments.api_day_change', ticker='__T__') }}`.replace('__T__', r.ticker)) .then(res => res.ok ? res.json() : null) .then(data => ({ id: r.id, data })) .catch(() => ({ id: r.id, data: null })) @@ -330,6 +331,7 @@ if (data && data.day_change !== undefined) { const badge = cell.querySelector('.chg-badge') || cell; badge.className = 'chg-badge ' + chgClass(data.day_change); + badge.title = `Open: ${fmtPrice(data.open)} → Current: ${fmtPrice(data.current)}`; badge.innerHTML = `` + (data.day_change >= 0 ? '+' : '') + @@ -390,12 +392,17 @@ // Info line const tfLabel = { '1W':'1 Week','1M':'1 Month','3M':'3 Months','6M':'6 Months','1Y':'1 Year' }[tf] || tf; + const dayChgHtml = data.day_change !== undefined + ? `Today (open→now): ${ + fmtChg(data.day_change, data.day_change_pct) + }  (open ${fmtPrice(data.open_price)})  ·  ` + : ''; infoEl.innerHTML = `Current: ${fmtPrice(data.current)}  ·  ` + - `${tfLabel} change: ${ + dayChgHtml + + `${tfLabel}: ${ fmtChg(data.period_change, data.period_change_pct) - }  ·  ` + - `Prev close: ${fmtPrice(data.prev_close)}`; + }`; const lineColor = data.period_change >= 0 ? '#10b981' : '#ef4444'; const fillColor = data.period_change >= 0 ? '#10b98118' : '#ef444418';