From 9646cd7c7acb375cd389638b240744062b666fea Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 25 Apr 2026 10:58:24 +1000 Subject: [PATCH] fix: derive portfolio header change from sum of per-stock changes for consistency --- invest/services.py | 60 +++++++++++++++++++++++++--------------- invest/template_views.py | 18 +++++++----- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/invest/services.py b/invest/services.py index df4f7a4..b0d8479 100644 --- a/invest/services.py +++ b/invest/services.py @@ -22,8 +22,9 @@ logger = logging.getLogger(__name__) _price_cache: dict[str, tuple[float, datetime]] = {} _PRICE_CACHE_TTL_SECONDS = 300 -_last_week_price_cache: dict[str, tuple[Optional[float], datetime]] = {} -_LAST_WEEK_CACHE_TTL_SECONDS = 3600 +# Cache for historical prices keyed by (stock_code, date_iso) with 1-hour TTL +_historical_price_cache: dict[str, tuple[Optional[float], datetime]] = {} +_HISTORICAL_CACHE_TTL_SECONDS = 3600 def _get_yfinance_price(stock_code: str) -> Optional[float]: @@ -39,27 +40,37 @@ def _get_yfinance_price(stock_code: str) -> Optional[float]: return None -def _get_last_week_price(stock_code: str) -> Optional[float]: - """Return the closing price ~7 calendar days ago (first available trading day in that window).""" +def _get_historical_price(stock_code: str, ref_date) -> Optional[float]: + """ + Return the closing price on or just before ref_date (handles weekends/holidays). + ref_date can be a date or datetime object. + """ + import datetime as dt + if hasattr(ref_date, 'date'): + ref_date = ref_date.date() + cache_key = f"{stock_code}:{ref_date.isoformat()}" now = datetime.now() - cached = _last_week_price_cache.get(stock_code) + cached = _historical_price_cache.get(cache_key) if cached: price, cached_at = cached - if (now - cached_at).total_seconds() < _LAST_WEEK_CACHE_TTL_SECONDS: + if (now - cached_at).total_seconds() < _HISTORICAL_CACHE_TTL_SECONDS: return price try: import yfinance as yf - import datetime as dt - end = dt.date.today() - dt.timedelta(days=5) - start = end - dt.timedelta(days=5) + # Look back up to 7 days to find the nearest prior trading day + start = ref_date - dt.timedelta(days=7) + end = ref_date + dt.timedelta(days=1) # end is exclusive in yfinance hist = yf.Ticker(stock_code).history(start=start.isoformat(), end=end.isoformat()) - price = float(hist["Close"].iloc[-1]) if not hist.empty else None + if hist.empty: + price = None + else: + price = float(hist["Close"].iloc[-1]) except Exception as exc: - logger.warning("yfinance last-week price failed for %s: %s", stock_code, exc) + logger.warning("yfinance historical price failed for %s @ %s: %s", stock_code, ref_date, exc) price = None - _last_week_price_cache[stock_code] = (price, now) + _historical_price_cache[cache_key] = (price, now) return price @@ -84,22 +95,27 @@ def get_current_price(stock_code: str) -> Optional[float]: # Portfolio value (live prices, no cost tracking) # --------------------------------------------------------------------------- -def get_portfolio_value(portfolio: Portfolio) -> dict: - """Return live holdings with current prices, total value, and weekly price change per stock.""" +def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict: + """ + Return live holdings with current prices, total value, and weekly price change per stock. + When reference_date is provided, per-stock change is relative to the closing price on that date + (the same baseline used by the portfolio-level change in the dashboard header). + """ holdings = [] total_value = Decimal('0') for stock in portfolio.stocks.filter(quantity__gt=0): price = get_current_price(stock.stock_code) or 0.0 value = Decimal(str(price)) * stock.quantity - last_week_price = _get_last_week_price(stock.stock_code) + + ref_price = _get_historical_price(stock.stock_code, reference_date) if reference_date else None price_change = None price_change_pct = None value_change = None - if price and last_week_price and last_week_price > 0: - price_change = round(price - last_week_price, 4) - price_change_pct = round((price_change / last_week_price) * 100, 2) + if price and ref_price and ref_price > 0: + price_change = round(price - ref_price, 4) + price_change_pct = round((price_change / ref_price) * 100, 2) value_change = round(price_change * float(stock.quantity), 2) holdings.append({ @@ -107,7 +123,7 @@ def get_portfolio_value(portfolio: Portfolio) -> dict: 'quantity': float(stock.quantity), 'current_price': price, 'current_value': float(value), - 'last_week_price': last_week_price, + 'ref_price': ref_price, 'price_change': price_change, 'price_change_pct': price_change_pct, 'value_change': value_change, @@ -233,12 +249,12 @@ def get_weekly_overview() -> dict: # Holdings sync (AI / manual) # --------------------------------------------------------------------------- -def get_all_holdings() -> list[dict]: +def get_all_holdings(reference_date=None) -> list[dict]: """ Return live holdings for every portfolio, grouped for dashboard display. + reference_date: if provided, per-stock week change is relative to closing prices on that date. Each entry: portfolio, portfolio_color_class, holdings (list), total_value """ - # Assign a distinct Tailwind color set per portfolio (cycled if more than defined) palette = [ {'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'}, {'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'}, @@ -250,7 +266,7 @@ def get_all_holdings() -> list[dict]: result = [] for idx, portfolio in enumerate(Portfolio.objects.all()): colors = palette[idx % len(palette)] - data = get_portfolio_value(portfolio) + data = get_portfolio_value(portfolio, reference_date=reference_date) result.append({ 'portfolio': portfolio, 'colors': colors, diff --git a/invest/template_views.py b/invest/template_views.py index 96fb1c0..8952312 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -19,7 +19,8 @@ def dashboard(request): fy_start = now.year if now.month >= 7 else now.year - 1 fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}" - all_holdings = get_all_holdings() + last_week_date = overview.get('last_week_date') + all_holdings = get_all_holdings(reference_date=last_week_date) # Merge snapshot data into each holdings group. # Change is computed as (live total – last snapshot), so the card header and # the change line are always consistent with the live holdings table. @@ -27,15 +28,18 @@ def dashboard(request): for group in all_holdings: row = rows_by_id.get(group['portfolio'].id, {}) group['last_snapshot_value'] = row.get('last_week_value') - live_val = group['total_value'] - last_val = group['last_snapshot_value'] - if live_val is not None and last_val and last_val > 0: - group['change'] = live_val - last_val - group['change_pct'] = round(((live_val - last_val) / last_val) * 100, 2) + group['position_count'] = row.get('position_count', len(group['holdings'])) + # Derive portfolio-level change by summing per-stock value changes, + # so the header is always consistent with the individual rows. + stock_changes = [s['value_change'] for s in group['holdings'] if s['value_change'] is not None] + if stock_changes: + total_change = sum(stock_changes) + ref_total = group['total_value'] - total_change + group['change'] = total_change + group['change_pct'] = round((total_change / ref_total) * 100, 2) if ref_total else None else: group['change'] = None group['change_pct'] = None - group['position_count'] = row.get('position_count', len(group['holdings'])) recent_transactions = ( Transaction.objects