From 9d063134802103b849ed31c0cea04123396015af Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Sat, 28 Mar 2026 09:18:26 +0100 Subject: [PATCH] feat(PRICING-002): add GLD/GC=F basis display on overview --- app/pages/overview.py | 79 ++++++++++++++++++ app/services/data_service.py | 154 ++++++++++++++++++++++++++++++++++- tests/test_basis_data.py | 148 +++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 tests/test_basis_data.py diff --git a/app/pages/overview.py b/app/pages/overview.py index cf59949..09ccb5b 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -152,6 +152,13 @@ async def overview_page(workspace_id: str) -> None: source=overview_source, updated_at=overview_updated_at, ) + + # Fetch basis data for GLD/GC=F comparison + try: + basis_data = await data_service.get_basis_data() + except Exception: + logger.exception("Failed to fetch basis data") + basis_data = None configured_gold_value = float(config.gold_value or 0.0) portfolio["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER portfolio["hedge_budget"] = float(config.monthly_budget) @@ -208,6 +215,78 @@ async def overview_page(workspace_id: str) -> None: ) with left_pane: + # GLD/GC=F Basis Card + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): + with ui.row().classes("w-full items-center justify-between gap-3"): + ui.label("GLD/GC=F Basis").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + if basis_data: + basis_badge_class = { + "green": "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", + "yellow": "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-500/15 dark:text-amber-300", + "red": "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300", + }.get( + basis_data["basis_status"], + "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700", + ) + ui.label(f"{basis_data['basis_label']} ({basis_data['basis_bps']:+.1f} bps)").classes( + basis_badge_class + ) + + if basis_data: + with ui.grid(columns=2).classes("w-full gap-4 mt-4"): + # GLD Implied Spot + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label("GLD Implied Spot").classes( + "text-sm font-medium text-slate-500 dark:text-slate-400" + ) + ui.label(f"${basis_data['gld_implied_spot']:,.2f}/oz").classes( + "text-2xl font-bold text-slate-900 dark:text-slate-50" + ) + ui.label( + f"GLD ${basis_data['gld_price']:.2f} ÷ {basis_data['gld_ounces_per_share']:.4f} oz/share" + ).classes("text-xs text-slate-500 dark:text-slate-400") + + # GC=F Adjusted + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label("GC=F Adjusted").classes("text-sm font-medium text-slate-500 dark:text-slate-400") + ui.label(f"${basis_data['gc_f_adjusted']:,.2f}/oz").classes( + "text-2xl font-bold text-slate-900 dark:text-slate-50" + ) + ui.label( + f"GC=F ${basis_data['gc_f_price']:.2f} - ${basis_data['contango_estimate']:.0f} contango" + ).classes("text-xs text-slate-500 dark:text-slate-400") + + # Basis explanation and after-hours notice + with ui.row().classes("w-full items-start gap-2 mt-4"): + ui.icon("info", size="xs").classes("text-slate-400 mt-0.5") + ui.label( + "Basis shows the premium/discount between GLD-implied gold and futures-adjusted spot. " + "Green < 25 bps (normal), Yellow 25-50 bps (elevated), Red > 50 bps (unusual)." + ).classes("text-xs text-slate-500 dark:text-slate-400") + + if basis_data["after_hours"]: + with ui.row().classes("w-full items-start gap-2 mt-2"): + ui.icon("schedule", size="xs").classes("text-amber-500 mt-0.5") + ui.label( + f"{basis_data['after_hours_note']} · GLD: {_format_timestamp(basis_data['gld_updated_at'])} · " + f"GC=F: {_format_timestamp(basis_data['gc_f_updated_at'])}" + ).classes("text-xs text-amber-700 dark:text-amber-300") + + # Warning for elevated basis + if basis_data["basis_status"] == "red": + ui.label( + f"⚠️ Elevated basis detected: {basis_data['basis_bps']:+.1f} bps. " + "This may indicate after-hours pricing gaps, physical stress, or arbitrage disruption." + ).classes("text-sm font-medium text-rose-700 dark:text-rose-300 mt-3") + else: + ui.label("Basis data temporarily unavailable").classes("text-sm text-slate-500 dark:text-slate-400") + with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): diff --git a/app/services/data_service.py b/app/services/data_service.py index b8986a9..d32836a 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -5,10 +5,11 @@ from __future__ import annotations import asyncio import logging import math -from datetime import datetime, timezone +from datetime import date, datetime, timezone from typing import Any from app.core.calculations import option_row_greeks +from app.domain.instruments import gld_ounces_per_share from app.services.cache import CacheService from app.strategies.engine import StrategySelectionEngine @@ -26,6 +27,7 @@ class DataService: def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None: self.cache = cache self.default_symbol = default_symbol + self.gc_f_symbol = "GC=F" # COMEX Gold Futures async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]: ticker = (symbol or self.default_symbol).upper() @@ -255,6 +257,156 @@ class DataService: ) return await self.get_options_chain_for_expiry(ticker_symbol, expirations[0]) + async def get_gc_futures(self) -> dict[str, Any]: + """Fetch GC=F (COMEX Gold Futures) quote. + + Returns a quote dict similar to get_quote but for gold futures. + Falls back gracefully if GC=F is unavailable. + """ + cache_key = f"quote:{self.gc_f_symbol}" + cached = await self.cache.get_json(cache_key) + if cached and isinstance(cached, dict): + try: + normalized_cached = self._normalize_quote_payload(cached, self.gc_f_symbol) + except ValueError: + normalized_cached = None + if normalized_cached is not None: + if normalized_cached != cached: + await self.cache.set_json(cache_key, normalized_cached) + return normalized_cached + + quote = self._normalize_quote_payload(await self._fetch_gc_futures(), self.gc_f_symbol) + await self.cache.set_json(cache_key, quote) + return quote + + async def _fetch_gc_futures(self) -> dict[str, Any]: + """Fetch GC=F from yfinance with graceful fallback.""" + if yf is None: + return self._fallback_gc_futures(source="fallback", error="yfinance is not installed") + + try: + ticker = yf.Ticker(self.gc_f_symbol) + history = await asyncio.to_thread(ticker.history, period="5d", interval="1d") + if history.empty: + return self._fallback_gc_futures(source="fallback", error="No history returned for GC=F") + + closes = history["Close"] + last = float(closes.iloc[-1]) + previous = float(closes.iloc[-2]) if len(closes) > 1 else last + change = round(last - previous, 4) + change_percent = round((change / previous) * 100, 4) if previous else 0.0 + + # Try to get more recent price from fast_info if available + try: + fast_price = ticker.fast_info.get("lastPrice", last) + if fast_price and fast_price > 0: + last = float(fast_price) + except Exception: + pass # Keep history close if fast_info unavailable + + return { + "symbol": self.gc_f_symbol, + "price": round(last, 4), + "quote_unit": "ozt", # Gold futures are per troy ounce + "change": change, + "change_percent": change_percent, + "updated_at": datetime.now(timezone.utc).isoformat(), + "source": "yfinance", + } + except Exception as exc: # pragma: no cover - network dependent + logger.warning("Failed to fetch %s from yfinance: %s", self.gc_f_symbol, exc) + return self._fallback_gc_futures(source="fallback", error=str(exc)) + + @staticmethod + def _fallback_gc_futures(source: str, error: str | None = None) -> dict[str, Any]: + """Fallback GC=F quote when live data unavailable.""" + payload = { + "symbol": "GC=F", + "price": 2700.0, # Fallback estimate + "quote_unit": "ozt", + "change": 0.0, + "change_percent": 0.0, + "updated_at": datetime.now(timezone.utc).isoformat(), + "source": source, + } + if error: + payload["error"] = error + return payload + + async def get_basis_data(self) -> dict[str, Any]: + """Get GLD/GC=F basis data for comparison. + + Returns: + Dict with GLD implied spot, GC=F adjusted price, basis in bps, and status info. + """ + gld_quote = await self.get_quote("GLD") + gc_f_quote = await self.get_gc_futures() + + # Use current date for GLD ounces calculation + ounces_per_share = float(gld_ounces_per_share(date.today())) + + # GLD implied spot = GLD_price / ounces_per_share + gld_price = gld_quote.get("price", 0.0) + gld_implied_spot = gld_price / ounces_per_share if ounces_per_share > 0 and gld_price > 0 else 0.0 + + # GC=F adjusted = (GC=F - contango_estimate) / 10 for naive comparison + # But actually GC=F is already per oz, so we just adjust for contango + gc_f_price = gc_f_quote.get("price", 0.0) + contango_estimate = 10.0 # Typical contango ~$10/oz + gc_f_adjusted = gc_f_price - contango_estimate if gc_f_price > 0 else 0.0 + + # Basis in bps = (GLD_implied_spot / GC=F_adjusted - 1) * 10000 + basis_bps = 0.0 + if gc_f_adjusted > 0 and gld_implied_spot > 0: + basis_bps = (gld_implied_spot / gc_f_adjusted - 1) * 10000 + + # Determine basis status + abs_basis = abs(basis_bps) + if abs_basis < 25: + basis_status = "green" + basis_label = "Normal" + elif abs_basis < 50: + basis_status = "yellow" + basis_label = "Elevated" + else: + basis_status = "red" + basis_label = "Warning" + + # After-hours check: compare timestamps + gld_updated = gld_quote.get("updated_at", "") + gc_f_updated = gc_f_quote.get("updated_at", "") + after_hours = False + after_hours_note = "" + + try: + gld_time = datetime.fromisoformat(gld_updated.replace("Z", "+00:00")) + gc_f_time = datetime.fromisoformat(gc_f_updated.replace("Z", "+00:00")) + # If GC=F updated much more recently, likely after-hours + time_diff = (gc_f_time - gld_time).total_seconds() + if time_diff > 3600: # More than 1 hour difference + after_hours = True + after_hours_note = "GLD quote may be stale (after-hours)" + except Exception: + pass + + return { + "gld_implied_spot": round(gld_implied_spot, 2), + "gld_price": round(gld_price, 2), + "gld_ounces_per_share": round(ounces_per_share, 4), + "gc_f_price": round(gc_f_price, 2), + "gc_f_adjusted": round(gc_f_adjusted, 2), + "contango_estimate": contango_estimate, + "basis_bps": round(basis_bps, 1), + "basis_status": basis_status, + "basis_label": basis_label, + "after_hours": after_hours, + "after_hours_note": after_hours_note, + "gld_updated_at": gld_updated, + "gc_f_updated_at": gc_f_updated, + "gld_source": gld_quote.get("source", "unknown"), + "gc_f_source": gc_f_quote.get("source", "unknown"), + } + async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]: ticker = (symbol or self.default_symbol).upper() quote = await self.get_quote(ticker) diff --git a/tests/test_basis_data.py b/tests/test_basis_data.py new file mode 100644 index 0000000..6433e30 --- /dev/null +++ b/tests/test_basis_data.py @@ -0,0 +1,148 @@ +"""Tests for GLD/GC=F basis data functionality.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from app.services.cache import CacheService +from app.services.data_service import DataService + + +class _CacheStub(CacheService): + """In-memory cache stub for unit testing.""" + + def __init__(self, initial: dict[str, object] | None = None) -> None: + self._store = dict(initial or {}) + self.write_count = 0 + + async def get_json(self, key: str): # type: ignore[override] + return self._store.get(key) + + async def set_json(self, key: str, value: object) -> None: # type: ignore[override] + self._store[key] = value + self.write_count += 1 + + async def delete(self, key: str) -> None: + self._store.pop(key, None) + + +@pytest.fixture +def cache_service(): + """Create a cache service for testing.""" + return _CacheStub() + + +@pytest.fixture +def data_service(cache_service): + """Create a data service for testing.""" + return DataService(cache_service) + + +@pytest.mark.asyncio +async def test_basis_data_structure(data_service: DataService) -> None: + """Test that basis data returns expected structure.""" + basis = await data_service.get_basis_data() + + # Check all required fields are present + assert "gld_implied_spot" in basis + assert "gld_price" in basis + assert "gld_ounces_per_share" in basis + assert "gc_f_price" in basis + assert "gc_f_adjusted" in basis + assert "contango_estimate" in basis + assert "basis_bps" in basis + assert "basis_status" in basis + assert "basis_label" in basis + assert "after_hours" in basis + assert "after_hours_note" in basis + assert "gld_updated_at" in basis + assert "gc_f_updated_at" in basis + assert "gld_source" in basis + assert "gc_f_source" in basis + + # Check types + assert isinstance(basis["gld_implied_spot"], float) + assert isinstance(basis["gld_price"], float) + assert isinstance(basis["gld_ounces_per_share"], float) + assert isinstance(basis["gc_f_price"], float) + assert isinstance(basis["gc_f_adjusted"], float) + assert isinstance(basis["contango_estimate"], float) + assert isinstance(basis["basis_bps"], float) + assert isinstance(basis["basis_status"], str) + assert isinstance(basis["basis_label"], str) + assert isinstance(basis["after_hours"], bool) + + # Check basis status is valid + assert basis["basis_status"] in ("green", "yellow", "red") + assert basis["basis_label"] in ("Normal", "Elevated", "Warning") + + # Check contango estimate is the expected default + assert basis["contango_estimate"] == 10.0 + + +@pytest.mark.asyncio +async def test_basis_calculation_logic(data_service: DataService) -> None: + """Test that basis calculation follows the expected formula.""" + basis = await data_service.get_basis_data() + + # Verify GLD implied spot calculation: GLD_price / ounces_per_share + if basis["gld_price"] > 0 and basis["gld_ounces_per_share"] > 0: + expected_implied = basis["gld_price"] / basis["gld_ounces_per_share"] + # Allow for rounding to 2 decimal places and real-world data variation + assert abs(basis["gld_implied_spot"] - expected_implied) < 2.0 + + # Verify GC=F adjusted calculation: GC=F_price - contango + if basis["gc_f_price"] > 0: + expected_adjusted = basis["gc_f_price"] - basis["contango_estimate"] + assert abs(basis["gc_f_adjusted"] - expected_adjusted) < 1.0 + + # Verify basis bps calculation: (GLD_implied / GC=F_adjusted - 1) * 10000 + if basis["gc_f_adjusted"] > 0 and basis["gld_implied_spot"] > 0: + expected_bps = (basis["gld_implied_spot"] / basis["gc_f_adjusted"] - 1) * 10000 + # Allow for rounding to 1 decimal place + assert abs(basis["basis_bps"] - expected_bps) < 1.0 + + +@pytest.mark.asyncio +async def test_basis_status_thresholds(data_service: DataService) -> None: + """Test that basis status thresholds are applied correctly.""" + basis = await data_service.get_basis_data() + + abs_basis = abs(basis["basis_bps"]) + + if abs_basis < 25: + assert basis["basis_status"] == "green" + assert basis["basis_label"] == "Normal" + elif abs_basis < 50: + assert basis["basis_status"] == "yellow" + assert basis["basis_label"] == "Elevated" + else: + assert basis["basis_status"] == "red" + assert basis["basis_label"] == "Warning" + + +@pytest.mark.asyncio +async def test_gc_f_fallback(data_service: DataService) -> None: + """Test that GC=F fallback works when live data unavailable.""" + gc_f = await data_service.get_gc_futures() + + assert "symbol" in gc_f + assert gc_f["symbol"] == "GC=F" + assert "price" in gc_f + assert isinstance(gc_f["price"], float) + assert gc_f["price"] > 0 # Should have a positive price (real or fallback) + assert gc_f["quote_unit"] == "ozt" + assert "source" in gc_f + + +def test_gld_ounces_per_share_current() -> None: + """Test GLD ounces per share calculation for current date.""" + from app.domain.instruments import gld_ounces_per_share + + ounces = gld_ounces_per_share(date.today()) + + # Should be around 0.0919 for 2026 (8.1% decay from 0.10) + assert 0.090 < float(ounces) < 0.094 + assert ounces > 0