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