feat(PRICING-002): add GLD/GC=F basis display on overview
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user