06/01 Optimize porfolio page
This commit is contained in:
@@ -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/<ticker>')
|
||||
@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/<ticker>')
|
||||
@login_required
|
||||
def api_price_history(ticker):
|
||||
|
||||
@@ -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)
|
||||
# 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,
|
||||
|
||||
@@ -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 =
|
||||
`<i class="bi ${chgIcon(data.day_change)}"></i>` +
|
||||
(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): <strong class="${data.day_change >= 0 ? 'text-income' : 'text-expense'}">${
|
||||
fmtChg(data.day_change, data.day_change_pct)
|
||||
}</strong> (open ${fmtPrice(data.open_price)}) · `
|
||||
: '';
|
||||
infoEl.innerHTML =
|
||||
`Current: <strong>${fmtPrice(data.current)}</strong> · ` +
|
||||
`${tfLabel} change: <strong class="${data.period_change >= 0 ? 'text-income' : 'text-expense'}">${
|
||||
dayChgHtml +
|
||||
`${tfLabel}: <strong class="${data.period_change >= 0 ? 'text-income' : 'text-expense'}">${
|
||||
fmtChg(data.period_change, data.period_change_pct)
|
||||
}</strong> · ` +
|
||||
`Prev close: ${fmtPrice(data.prev_close)}`;
|
||||
}</strong>`;
|
||||
|
||||
const lineColor = data.period_change >= 0 ? '#10b981' : '#ef4444';
|
||||
const fillColor = data.period_change >= 0 ? '#10b98118' : '#ef444418';
|
||||
|
||||
Reference in New Issue
Block a user