mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
fix: derive portfolio header change from sum of per-stock changes for consistency
This commit is contained in:
+38
-22
@@ -22,8 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
_price_cache: dict[str, tuple[float, datetime]] = {}
|
||||
_PRICE_CACHE_TTL_SECONDS = 300
|
||||
|
||||
_last_week_price_cache: dict[str, tuple[Optional[float], datetime]] = {}
|
||||
_LAST_WEEK_CACHE_TTL_SECONDS = 3600
|
||||
# Cache for historical prices keyed by (stock_code, date_iso) with 1-hour TTL
|
||||
_historical_price_cache: dict[str, tuple[Optional[float], datetime]] = {}
|
||||
_HISTORICAL_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
def _get_yfinance_price(stock_code: str) -> Optional[float]:
|
||||
@@ -39,27 +40,37 @@ def _get_yfinance_price(stock_code: str) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_last_week_price(stock_code: str) -> Optional[float]:
|
||||
"""Return the closing price ~7 calendar days ago (first available trading day in that window)."""
|
||||
def _get_historical_price(stock_code: str, ref_date) -> Optional[float]:
|
||||
"""
|
||||
Return the closing price on or just before ref_date (handles weekends/holidays).
|
||||
ref_date can be a date or datetime object.
|
||||
"""
|
||||
import datetime as dt
|
||||
if hasattr(ref_date, 'date'):
|
||||
ref_date = ref_date.date()
|
||||
cache_key = f"{stock_code}:{ref_date.isoformat()}"
|
||||
now = datetime.now()
|
||||
cached = _last_week_price_cache.get(stock_code)
|
||||
cached = _historical_price_cache.get(cache_key)
|
||||
if cached:
|
||||
price, cached_at = cached
|
||||
if (now - cached_at).total_seconds() < _LAST_WEEK_CACHE_TTL_SECONDS:
|
||||
if (now - cached_at).total_seconds() < _HISTORICAL_CACHE_TTL_SECONDS:
|
||||
return price
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
import datetime as dt
|
||||
end = dt.date.today() - dt.timedelta(days=5)
|
||||
start = end - dt.timedelta(days=5)
|
||||
# Look back up to 7 days to find the nearest prior trading day
|
||||
start = ref_date - dt.timedelta(days=7)
|
||||
end = ref_date + dt.timedelta(days=1) # end is exclusive in yfinance
|
||||
hist = yf.Ticker(stock_code).history(start=start.isoformat(), end=end.isoformat())
|
||||
price = float(hist["Close"].iloc[-1]) if not hist.empty else None
|
||||
if hist.empty:
|
||||
price = None
|
||||
else:
|
||||
price = float(hist["Close"].iloc[-1])
|
||||
except Exception as exc:
|
||||
logger.warning("yfinance last-week price failed for %s: %s", stock_code, exc)
|
||||
logger.warning("yfinance historical price failed for %s @ %s: %s", stock_code, ref_date, exc)
|
||||
price = None
|
||||
|
||||
_last_week_price_cache[stock_code] = (price, now)
|
||||
_historical_price_cache[cache_key] = (price, now)
|
||||
return price
|
||||
|
||||
|
||||
@@ -84,22 +95,27 @@ def get_current_price(stock_code: str) -> Optional[float]:
|
||||
# Portfolio value (live prices, no cost tracking)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_portfolio_value(portfolio: Portfolio) -> dict:
|
||||
"""Return live holdings with current prices, total value, and weekly price change per stock."""
|
||||
def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
|
||||
"""
|
||||
Return live holdings with current prices, total value, and weekly price change per stock.
|
||||
When reference_date is provided, per-stock change is relative to the closing price on that date
|
||||
(the same baseline used by the portfolio-level change in the dashboard header).
|
||||
"""
|
||||
holdings = []
|
||||
total_value = Decimal('0')
|
||||
|
||||
for stock in portfolio.stocks.filter(quantity__gt=0):
|
||||
price = get_current_price(stock.stock_code) or 0.0
|
||||
value = Decimal(str(price)) * stock.quantity
|
||||
last_week_price = _get_last_week_price(stock.stock_code)
|
||||
|
||||
ref_price = _get_historical_price(stock.stock_code, reference_date) if reference_date else None
|
||||
|
||||
price_change = None
|
||||
price_change_pct = None
|
||||
value_change = None
|
||||
if price and last_week_price and last_week_price > 0:
|
||||
price_change = round(price - last_week_price, 4)
|
||||
price_change_pct = round((price_change / last_week_price) * 100, 2)
|
||||
if price and ref_price and ref_price > 0:
|
||||
price_change = round(price - ref_price, 4)
|
||||
price_change_pct = round((price_change / ref_price) * 100, 2)
|
||||
value_change = round(price_change * float(stock.quantity), 2)
|
||||
|
||||
holdings.append({
|
||||
@@ -107,7 +123,7 @@ def get_portfolio_value(portfolio: Portfolio) -> dict:
|
||||
'quantity': float(stock.quantity),
|
||||
'current_price': price,
|
||||
'current_value': float(value),
|
||||
'last_week_price': last_week_price,
|
||||
'ref_price': ref_price,
|
||||
'price_change': price_change,
|
||||
'price_change_pct': price_change_pct,
|
||||
'value_change': value_change,
|
||||
@@ -233,12 +249,12 @@ def get_weekly_overview() -> dict:
|
||||
# Holdings sync (AI / manual)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_all_holdings() -> list[dict]:
|
||||
def get_all_holdings(reference_date=None) -> list[dict]:
|
||||
"""
|
||||
Return live holdings for every portfolio, grouped for dashboard display.
|
||||
reference_date: if provided, per-stock week change is relative to closing prices on that date.
|
||||
Each entry: portfolio, portfolio_color_class, holdings (list), total_value
|
||||
"""
|
||||
# Assign a distinct Tailwind color set per portfolio (cycled if more than defined)
|
||||
palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
|
||||
@@ -250,7 +266,7 @@ def get_all_holdings() -> list[dict]:
|
||||
result = []
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
colors = palette[idx % len(palette)]
|
||||
data = get_portfolio_value(portfolio)
|
||||
data = get_portfolio_value(portfolio, reference_date=reference_date)
|
||||
result.append({
|
||||
'portfolio': portfolio,
|
||||
'colors': colors,
|
||||
|
||||
@@ -19,7 +19,8 @@ def dashboard(request):
|
||||
fy_start = now.year if now.month >= 7 else now.year - 1
|
||||
fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}"
|
||||
|
||||
all_holdings = get_all_holdings()
|
||||
last_week_date = overview.get('last_week_date')
|
||||
all_holdings = get_all_holdings(reference_date=last_week_date)
|
||||
# Merge snapshot data into each holdings group.
|
||||
# Change is computed as (live total – last snapshot), so the card header and
|
||||
# the change line are always consistent with the live holdings table.
|
||||
@@ -27,15 +28,18 @@ def dashboard(request):
|
||||
for group in all_holdings:
|
||||
row = rows_by_id.get(group['portfolio'].id, {})
|
||||
group['last_snapshot_value'] = row.get('last_week_value')
|
||||
live_val = group['total_value']
|
||||
last_val = group['last_snapshot_value']
|
||||
if live_val is not None and last_val and last_val > 0:
|
||||
group['change'] = live_val - last_val
|
||||
group['change_pct'] = round(((live_val - last_val) / last_val) * 100, 2)
|
||||
group['position_count'] = row.get('position_count', len(group['holdings']))
|
||||
# Derive portfolio-level change by summing per-stock value changes,
|
||||
# so the header is always consistent with the individual rows.
|
||||
stock_changes = [s['value_change'] for s in group['holdings'] if s['value_change'] is not None]
|
||||
if stock_changes:
|
||||
total_change = sum(stock_changes)
|
||||
ref_total = group['total_value'] - total_change
|
||||
group['change'] = total_change
|
||||
group['change_pct'] = round((total_change / ref_total) * 100, 2) if ref_total else None
|
||||
else:
|
||||
group['change'] = None
|
||||
group['change_pct'] = None
|
||||
group['position_count'] = row.get('position_count', len(group['holdings']))
|
||||
|
||||
recent_transactions = (
|
||||
Transaction.objects
|
||||
|
||||
Reference in New Issue
Block a user