diff --git a/invest/services.py b/invest/services.py index b0d8479..34a0ad5 100644 --- a/invest/services.py +++ b/invest/services.py @@ -341,23 +341,85 @@ 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 - # Per-portfolio cumulative % series — each portfolio bases off its own first week + # Date range for price lookups (portfolio series + benchmarks share these) + 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() + + # 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 [] + + 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. portfolio_colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C'] datasets = [] - for idx, portfolio in enumerate(Portfolio.objects.all()): - pw = portfolio_weekly.get(portfolio.id, {}) - if not pw: + for idx, portfolio in enumerate(Portfolio.objects.prefetch_related('stocks').all()): + current_stocks = list(portfolio.stocks.filter(quantity__gt=0)) + if not current_stocks: continue - # base = value in the earliest week available for this portfolio - first_week = min(pw.keys()) - base_val = pw[first_week][1] + + 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) if not base_val: continue data_pts = [ - round((pw[wk][1] - base_val) / base_val * 100, 2) if wk in pw else None - for wk in all_week_keys + round((v - base_val) / base_val * 100, 2) if v is not None else None + for v in week_totals ] color = portfolio_colors[idx % len(portfolio_colors)] datasets.append({ @@ -373,11 +435,7 @@ def _build_performance_chart_data() -> Optional[str]: 'fill': False, }) - # Benchmark series — start 7 days before the earliest label date - 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() + # Benchmark series — start_str / end_str already computed above def _benchmark(ticker: str, label: str, color: str) -> Optional[dict]: from .models import BenchmarkPrice