update performance chart logic!

This commit is contained in:
2026-05-02 20:36:32 +10:00
parent f60c6bff67
commit 68dd32d2cd
+72 -14
View File
@@ -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