cache benchmark prices in DB, refresh weekly

This commit is contained in:
2026-04-18 22:58:25 +10:00
parent 221cdcdbee
commit 56bb5df57c
4 changed files with 127 additions and 6 deletions
+27
View File
@@ -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')},
},
),
]
+17
View File
@@ -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}"
+33 -6
View File
@@ -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):
+50
View File
@@ -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)