mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
cache benchmark prices in DB, refresh weekly
This commit is contained in:
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user