From 56bb5df57c3d89f2f48c5041ac07826ff2605e67 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 18 Apr 2026 22:58:25 +1000 Subject: [PATCH] cache benchmark prices in DB, refresh weekly --- invest/migrations/0003_benchmarkprice.py | 27 +++++++++++++ invest/models.py | 17 ++++++++ invest/services.py | 39 +++++++++++++++--- invest/tasks.py | 50 ++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 invest/migrations/0003_benchmarkprice.py diff --git a/invest/migrations/0003_benchmarkprice.py b/invest/migrations/0003_benchmarkprice.py new file mode 100644 index 0000000..ca63072 --- /dev/null +++ b/invest/migrations/0003_benchmarkprice.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.12 on 2026-04-18 12:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invest', '0002_remove_transaction_price_per_share_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BenchmarkPrice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticker', models.CharField(max_length=10)), + ('date', models.DateField()), + ('close', models.DecimalField(decimal_places=4, max_digits=12)), + ], + options={ + 'ordering': ['ticker', 'date'], + 'indexes': [models.Index(fields=['ticker', 'date'], name='invest_benc_ticker_17637a_idx')], + 'unique_together': {('ticker', 'date')}, + }, + ), + ] diff --git a/invest/models.py b/invest/models.py index 699b7c1..453c135 100644 --- a/invest/models.py +++ b/invest/models.py @@ -72,3 +72,20 @@ class PortfolioSnapshot(models.Model): def __str__(self): return f"{self.portfolio.name} @ {self.captured_at:%Y-%m-%d %H:%M}: ${self.total_value}" + + +class BenchmarkPrice(models.Model): + """Daily closing price for a benchmark ticker (SPY, QQQ, etc.). Cached from yfinance.""" + ticker = models.CharField(max_length=10) + date = models.DateField() + close = models.DecimalField(max_digits=12, decimal_places=4) + + class Meta: + unique_together = [('ticker', 'date')] + indexes = [ + models.Index(fields=['ticker', 'date']), + ] + ordering = ['ticker', 'date'] + + def __str__(self): + return f"{self.ticker} {self.date}: ${self.close}" diff --git a/invest/services.py b/invest/services.py index 4f2f1a4..ee17322 100644 --- a/invest/services.py +++ b/invest/services.py @@ -303,15 +303,42 @@ def _build_performance_chart_data() -> Optional[str]: end_str = (latest_date + timedelta(days=5)).isoformat() 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 + 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 + + if need_refresh: + try: + import yfinance as yf + hist = yf.Ticker(ticker).history(start=start_str, end=end_str) + if not hist.empty: + rows = [] + for d, v in hist['Close'].items(): + date_val = d.date() if hasattr(d, 'date') else d + rows.append(BenchmarkPrice(ticker=ticker, date=date_val, close=round(float(v), 4))) + BenchmarkPrice.objects.bulk_create(rows, update_conflicts=True, + unique_fields=['ticker', 'date'], + update_fields=['close']) + logger.info("invest: cached %d prices for %s", len(rows), ticker) + except Exception as exc: + logger.warning("benchmark %s yfinance fetch failed: %s", ticker, exc) + try: - import yfinance as yf - hist = yf.Ticker(ticker).history(start=start_str, end=end_str) - if hist.empty: - return None closes = { - (d.date() if hasattr(d, 'date') else d): float(v) - for d, v in hist['Close'].items() + row.date: float(row.close) + for row in BenchmarkPrice.objects.filter( + ticker=ticker, + date__gte=earliest_date - timedelta(days=7), + date__lte=latest_date + timedelta(days=5), + ).order_by('date') } + if not closes: + return None sorted_trading_days = sorted(closes.keys()) def closest_close(target): diff --git a/invest/tasks.py b/invest/tasks.py index 89a6849..1ab9434 100644 --- a/invest/tasks.py +++ b/invest/tasks.py @@ -44,3 +44,53 @@ def snapshot_all_portfolios(): logger.error("invest: snapshot failed for %s: %s", portfolio.name, exc, exc_info=True) logger.info("invest: snapshot complete — %d portfolios", count) + # Also refresh benchmark prices so the chart has up-to-date SPY/QQQ data + refresh_benchmark_prices() + + +def refresh_benchmark_prices(tickers=None): + """ + Fetch and cache daily closing prices for benchmark tickers (SPY, QQQ). + Skips the fetch if data is already fresh (latest date within last 7 days). + Called weekly alongside snapshot_all_portfolios. + """ + import datetime as dt + import yfinance as yf + from .models import BenchmarkPrice + + if tickers is None: + tickers = ['SPY', 'QQQ'] + + today = dt.date.today() + start = (today - dt.timedelta(days=365 * 2)).isoformat() # 2 years of history + end = (today + dt.timedelta(days=1)).isoformat() + + for ticker in tickers: + latest = ( + BenchmarkPrice.objects.filter(ticker=ticker) + .order_by('-date') + .values_list('date', flat=True) + .first() + ) + if latest and (today - latest).days <= 7: + logger.info("invest: benchmark %s is fresh (latest=%s), skipping", ticker, latest) + continue + + try: + hist = yf.Ticker(ticker).history(start=start, end=end) + if hist.empty: + logger.warning("invest: no data for benchmark %s", ticker) + continue + rows = [] + for d, v in hist['Close'].items(): + date_val = d.date() if hasattr(d, 'date') else d + rows.append(BenchmarkPrice(ticker=ticker, date=date_val, close=round(float(v), 4))) + BenchmarkPrice.objects.bulk_create( + rows, + update_conflicts=True, + unique_fields=['ticker', 'date'], + update_fields=['close'], + ) + logger.info("invest: refreshed %d prices for %s (latest=%s)", len(rows), ticker, today) + except Exception as exc: + logger.error("invest: benchmark refresh failed for %s: %s", ticker, exc, exc_info=True)