feat(PRICING-002): add GLD/GC=F basis display on overview

This commit is contained in:
Bu5hm4nn
2026-03-28 09:18:26 +01:00
parent 894d88f72f
commit 9d06313480
3 changed files with 380 additions and 1 deletions

View File

@@ -152,6 +152,13 @@ async def overview_page(workspace_id: str) -> None:
source=overview_source, source=overview_source,
updated_at=overview_updated_at, 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) 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["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER
portfolio["hedge_budget"] = float(config.monthly_budget) portfolio["hedge_budget"] = float(config.monthly_budget)
@@ -208,6 +215,78 @@ async def overview_page(workspace_id: str) -> None:
) )
with left_pane: 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( with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
): ):

View File

@@ -5,10 +5,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math import math
from datetime import datetime, timezone from datetime import date, datetime, timezone
from typing import Any from typing import Any
from app.core.calculations import option_row_greeks 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.services.cache import CacheService
from app.strategies.engine import StrategySelectionEngine from app.strategies.engine import StrategySelectionEngine
@@ -26,6 +27,7 @@ class DataService:
def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None: def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None:
self.cache = cache self.cache = cache
self.default_symbol = default_symbol 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]: async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper() 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]) 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]: async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper() ticker = (symbol or self.default_symbol).upper()
quote = await self.get_quote(ticker) quote = await self.get_quote(ticker)

148
tests/test_basis_data.py Normal file
View File

@@ -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