Merge branch 'feature/PRICING-002-basis-display'

This commit is contained in:
Bu5hm4nn
2026-03-28 09:18:29 +01:00
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,
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"
):

View File

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

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