fix: derive portfolio header change from sum of per-stock changes for consistency

This commit is contained in:
2026-04-25 10:58:24 +10:00
parent b4a02654d0
commit 9646cd7c7a
2 changed files with 49 additions and 29 deletions
+38 -22
View File
@@ -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,
+11 -7
View File
@@ -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