From b52b821bafda9b59cd2276b55480b7412745d79a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 2 May 2026 20:55:02 +1000 Subject: [PATCH] test change rate --- invest/services.py | 110 ++++++++++++++++----------------------------- 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/invest/services.py b/invest/services.py index 34a0ad5..9aabbf5 100644 --- a/invest/services.py +++ b/invest/services.py @@ -341,86 +341,49 @@ def _build_performance_chart_data() -> Optional[str]: if wk not in week_label_date or d > week_label_date[wk]: week_label_date[wk] = d - # Date range for price lookups (portfolio series + benchmarks share these) + import datetime as dt + today = dt.date.today() + earliest_date = week_label_date[all_week_keys[0]] latest_date = week_label_date[all_week_keys[-1]] start_str = (earliest_date - timedelta(days=7)).isoformat() - end_str = (latest_date + timedelta(days=5)).isoformat() + # Always fetch up to today so benchmarks include the current (unsnapshot'd) week + end_str = (today + timedelta(days=1)).isoformat() - # Pre-fetch the full closing-price history for every currently-held stock in one - # yfinance call per ticker (not per week). This avoids O(stocks × weeks) fetches - # and is the same price series used by the dashboard cards. - def _fetch_closes(stock_code: str) -> list: - """Return [(date, close), …] sorted ascending for stock_code over the chart period.""" - try: - import yfinance as yf - hist = yf.Ticker(stock_code).history(start=start_str, end=end_str, auto_adjust=True) - if hist.empty: - return [] - closes = [] - for d, price in hist['Close'].items(): - date_val = d.date() if hasattr(d, 'date') else d - closes.append((date_val, float(price))) - return sorted(closes) - except Exception as exc: - logger.warning("invest: price history failed for %s: %s", stock_code, exc) - return [] + # Whether today is past the last snapshot — if so, append a live "Today" point + add_today = today > latest_date - def _closest_close(closes: list, target_date) -> Optional[float]: - """Most recent closing price on or before target_date.""" - result = None - for d, price in closes: - if d <= target_date: - result = price - else: - break - return result - - stock_histories: dict = {} - for _p in Portfolio.objects.prefetch_related('stocks').all(): - for _s in _p.stocks.filter(quantity__gt=0): - if _s.stock_code not in stock_histories: - stock_histories[_s.stock_code] = _fetch_closes(_s.stock_code) - - # Per-portfolio cumulative % series — current holdings × historical prices. - # - # Using current share counts valued at each historical week's price removes the - # distortion caused by transactions (capital injections / withdrawals): a BUY that - # injects cash no longer inflates subsequent snapshot totals, and a SELL no longer - # deflates them. The resulting series reflects pure market price performance of the - # positions actually held today — identical in methodology to the week-change figures - # shown on each portfolio card on the dashboard. + # Per-portfolio cumulative % series — based on the actual weekly PortfolioSnapshot totals. + # The snapshot captures the true portfolio value at that moment (including all positions, + # before and after rebalancing), so it is the authoritative measure of portfolio performance. + # When add_today is True, the current live value is appended as an extra "Today" data point + # so the chart always includes the current week even before the Saturday snapshot runs. portfolio_colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C'] datasets = [] - for idx, portfolio in enumerate(Portfolio.objects.prefetch_related('stocks').all()): - current_stocks = list(portfolio.stocks.filter(quantity__gt=0)) - if not current_stocks: + for idx, portfolio in enumerate(Portfolio.objects.all()): + pw = portfolio_weekly.get(portfolio.id, {}) + if not pw: continue - - week_totals = [] - for wk in all_week_keys: - label_date = week_label_date[wk] - total = 0.0 - all_priced = True - for stock in current_stocks: - closes = stock_histories.get(stock.stock_code, []) - price = _closest_close(closes, label_date) - if price is None: - all_priced = False - break - total += price * float(stock.quantity) - week_totals.append(total if all_priced else None) - - # Base = first week where every position has a price - base_val = next((v for v in week_totals if v), None) + first_week = min(pw.keys()) + base_val = pw[first_week][1] if not base_val: continue data_pts = [ - round((v - base_val) / base_val * 100, 2) if v is not None else None - for v in week_totals + round((pw[wk][1] - base_val) / base_val * 100, 2) if wk in pw else None + for wk in all_week_keys ] + + if add_today: + try: + live_total = get_portfolio_value(portfolio)['total_value'] + today_pct = round((live_total - base_val) / base_val * 100, 2) if live_total else None + except Exception as exc: + logger.warning("invest: live value for today chart point failed (%s): %s", portfolio.name, exc) + today_pct = None + data_pts.append(today_pct) + color = portfolio_colors[idx % len(portfolio_colors)] datasets.append({ 'label': portfolio.name, @@ -435,14 +398,12 @@ def _build_performance_chart_data() -> Optional[str]: 'fill': False, }) - # Benchmark series — start_str / end_str already computed above + # Benchmark series — fetched up to today so the final point aligns with portfolio live values def _benchmark(ticker: str, label: str, color: str) -> Optional[dict]: from .models import BenchmarkPrice - import datetime as dt - today = dt.date.today() - # Check DB coverage — refresh if we have no rows or latest price > 7 days stale + # Check DB coverage — refresh if no rows or latest price is stale qs = BenchmarkPrice.objects.filter(ticker=ticker, date__gte=earliest_date - timedelta(days=7)) latest_db_date = qs.order_by('-date').values_list('date', flat=True).first() need_refresh = latest_db_date is None or (today - latest_db_date).days > 7 @@ -469,7 +430,7 @@ def _build_performance_chart_data() -> Optional[str]: for row in BenchmarkPrice.objects.filter( ticker=ticker, date__gte=earliest_date - timedelta(days=7), - date__lte=latest_date + timedelta(days=5), + date__lte=today + timedelta(days=1), ).order_by('date') } if not closes: @@ -488,6 +449,11 @@ def _build_performance_chart_data() -> Optional[str]: if closest_close(week_label_date[wk]) is not None else None for wk in all_week_keys ] + if add_today: + today_close = closest_close(today) + data_pts.append( + round((today_close - base_price) / base_price * 100, 2) if today_close else None + ) return { 'label': label, 'data': data_pts, @@ -512,6 +478,8 @@ def _build_performance_chart_data() -> Optional[str]: datasets.append(qqq) labels = [week_label_date[wk].strftime('%b %-d') for wk in all_week_keys] + if add_today: + labels.append('Today') return json.dumps({'labels': labels, 'datasets': datasets})