feat(DATA-004): add underlying instrument selector
This commit is contained in:
@@ -4,16 +4,43 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
|
||||
from app.domain.backtesting_math import AssetQuantity, PricePerAsset
|
||||
from app.domain.units import BaseCurrency, PricePerWeight, Weight, WeightUnit
|
||||
|
||||
|
||||
class Underlying(str, Enum):
|
||||
"""Supported underlying instruments for options evaluation."""
|
||||
|
||||
GLD = "GLD"
|
||||
GC_F = "GC=F"
|
||||
|
||||
def display_name(self) -> str:
|
||||
"""Human-readable display name."""
|
||||
return {
|
||||
Underlying.GLD: "SPDR Gold Shares ETF",
|
||||
Underlying.GC_F: "Gold Futures (COMEX)",
|
||||
}.get(self, self.value)
|
||||
|
||||
def description(self) -> str:
|
||||
"""Description of the underlying and data source."""
|
||||
return {
|
||||
Underlying.GLD: "SPDR Gold Shares ETF (live data via yfinance)",
|
||||
Underlying.GC_F: "Gold Futures (coming soon)",
|
||||
}.get(self, "")
|
||||
|
||||
|
||||
# GLD expense ratio decay parameters (from docs/GLD_BASIS_RESEARCH.md)
|
||||
# Formula: ounces_per_share = 0.10 * e^(-0.004 * years_since_2004)
|
||||
GLD_INITIAL_OUNCES_PER_SHARE = Decimal("0.10")
|
||||
GLD_EXPENSE_DECAY_RATE = Decimal("0.004") # 0.4% annual decay
|
||||
GLD_LAUNCH_YEAR = 2004
|
||||
|
||||
# GC=F contract specifications
|
||||
GC_F_OUNCES_PER_CONTRACT = Decimal("100") # 100 troy oz per contract
|
||||
GC_F_QUOTE_CURRENCY = BaseCurrency.USD
|
||||
|
||||
|
||||
def gld_ounces_per_share(reference_date: date | None = None) -> Decimal:
|
||||
"""
|
||||
@@ -105,11 +132,23 @@ _GLD = InstrumentMetadata(
|
||||
weight_per_share=Weight(amount=gld_ounces_per_share(), unit=WeightUnit.OUNCE_TROY),
|
||||
)
|
||||
|
||||
_GC_F = InstrumentMetadata(
|
||||
symbol="GC=F",
|
||||
quote_currency=GC_F_QUOTE_CURRENCY,
|
||||
weight_per_share=Weight(amount=GC_F_OUNCES_PER_CONTRACT, unit=WeightUnit.OUNCE_TROY),
|
||||
)
|
||||
|
||||
_INSTRUMENTS: dict[str, InstrumentMetadata] = {
|
||||
_GLD.symbol: _GLD,
|
||||
_GC_F.symbol: _GC_F,
|
||||
}
|
||||
|
||||
|
||||
def supported_underlyings() -> list[Underlying]:
|
||||
"""Return list of supported underlying instruments."""
|
||||
return list(Underlying)
|
||||
|
||||
|
||||
def instrument_metadata(symbol: str) -> InstrumentMetadata:
|
||||
normalized = str(symbol).strip().upper()
|
||||
metadata = _INSTRUMENTS.get(normalized)
|
||||
|
||||
@@ -120,7 +120,7 @@ async def lifespan(app: FastAPI):
|
||||
app.state.settings = settings
|
||||
app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl)
|
||||
await app.state.cache.connect()
|
||||
app.state.data_service = DataService(app.state.cache, default_symbol=settings.default_symbol)
|
||||
app.state.data_service = DataService(app.state.cache, default_underlying=settings.default_symbol)
|
||||
set_data_service(app.state.data_service)
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
app.state.publisher_task = asyncio.create_task(publish_updates(app))
|
||||
|
||||
@@ -109,6 +109,9 @@ class PortfolioConfig:
|
||||
fallback_source: str = "yfinance"
|
||||
refresh_interval: int = 5
|
||||
|
||||
# Underlying instrument selection
|
||||
underlying: str = "GLD"
|
||||
|
||||
# Alert settings
|
||||
volatility_spike: float = 0.25
|
||||
spot_drawdown: float = 7.5
|
||||
@@ -223,6 +226,7 @@ class PortfolioConfig:
|
||||
"primary_source": self.primary_source,
|
||||
"fallback_source": self.fallback_source,
|
||||
"refresh_interval": self.refresh_interval,
|
||||
"underlying": self.underlying,
|
||||
"volatility_spike": self.volatility_spike,
|
||||
"spot_drawdown": self.spot_drawdown,
|
||||
"email_alerts": self.email_alerts,
|
||||
@@ -285,6 +289,7 @@ class PortfolioRepository:
|
||||
"primary_source",
|
||||
"fallback_source",
|
||||
"refresh_interval",
|
||||
"underlying", # optional with default "GLD"
|
||||
"volatility_spike",
|
||||
"spot_drawdown",
|
||||
"email_alerts",
|
||||
@@ -344,10 +349,14 @@ class PortfolioRepository:
|
||||
upgraded = cls._upgrade_legacy_default_workspace(deserialized)
|
||||
return PortfolioConfig.from_dict(upgraded)
|
||||
|
||||
# Fields that must be present in persisted payloads
|
||||
# (underlying is optional with default "GLD")
|
||||
_REQUIRED_FIELDS = _PERSISTED_FIELDS - {"underlying"}
|
||||
|
||||
@classmethod
|
||||
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:
|
||||
keys = set(payload.keys())
|
||||
missing = sorted(cls._PERSISTED_FIELDS - keys)
|
||||
missing = sorted(cls._REQUIRED_FIELDS - keys)
|
||||
unknown = sorted(keys - cls._PERSISTED_FIELDS)
|
||||
if missing or unknown:
|
||||
details: list[str] = []
|
||||
|
||||
@@ -86,9 +86,10 @@ async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str
|
||||
|
||||
try:
|
||||
data_service = get_data_service()
|
||||
quote = await data_service.get_quote(data_service.default_symbol)
|
||||
underlying = config.underlying or "GLD"
|
||||
quote = await data_service.get_quote(underlying)
|
||||
spot, source, updated_at = resolve_portfolio_spot_from_quote(
|
||||
config, quote, fallback_symbol=data_service.default_symbol
|
||||
config, quote, fallback_symbol=underlying
|
||||
)
|
||||
portfolio = portfolio_snapshot(config, runtime_spot_price=spot)
|
||||
return portfolio, source, updated_at
|
||||
@@ -120,12 +121,25 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
|
||||
)
|
||||
updated_label = f"Quote timestamp: {quote_updated_at}" if quote_updated_at else "Quote timestamp: unavailable"
|
||||
|
||||
# Get underlying for display
|
||||
underlying = "GLD"
|
||||
if workspace_id:
|
||||
try:
|
||||
repo = get_workspace_repository()
|
||||
config = repo.load_portfolio_config(workspace_id)
|
||||
underlying = config.underlying or "GLD"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with dashboard_page(
|
||||
"Hedge Analysis",
|
||||
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.",
|
||||
f"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts for {underlying}.",
|
||||
"hedge",
|
||||
workspace_id=workspace_id,
|
||||
):
|
||||
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
|
||||
ui.label(f"Active underlying: {underlying}").classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
|
||||
left_pane, right_pane = split_page_panes(
|
||||
left_testid="hedge-left-pane",
|
||||
right_testid="hedge-right-pane",
|
||||
|
||||
@@ -141,7 +141,8 @@ async def overview_page(workspace_id: str) -> None:
|
||||
|
||||
config = repo.load_portfolio_config(workspace_id)
|
||||
data_service = get_data_service()
|
||||
symbol = data_service.default_symbol
|
||||
underlying = config.underlying or "GLD"
|
||||
symbol = underlying
|
||||
quote = await data_service.get_quote(symbol)
|
||||
overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot(
|
||||
config, quote, fallback_symbol=symbol
|
||||
@@ -199,14 +200,14 @@ async def overview_page(workspace_id: str) -> None:
|
||||
|
||||
with dashboard_page(
|
||||
"Overview",
|
||||
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
|
||||
f"Portfolio health, LTV risk, and quick strategy guidance for the current {underlying}-backed loan.",
|
||||
"overview",
|
||||
workspace_id=workspace_id,
|
||||
):
|
||||
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
|
||||
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
ui.label(
|
||||
f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
|
||||
f"Active underlying: {underlying} · Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
|
||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
|
||||
left_pane, right_pane = split_page_panes(
|
||||
|
||||
@@ -121,6 +121,7 @@ def settings_page(workspace_id: str) -> None:
|
||||
primary_source=str(primary_source.value),
|
||||
fallback_source=str(fallback_source.value),
|
||||
refresh_interval=parsed_refresh_interval,
|
||||
underlying=str(underlying.value),
|
||||
volatility_spike=float(vol_alert.value),
|
||||
spot_drawdown=float(price_alert.value),
|
||||
email_alerts=bool(email_alerts.value),
|
||||
@@ -244,6 +245,14 @@ def settings_page(workspace_id: str) -> None:
|
||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
):
|
||||
ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||
underlying = ui.select(
|
||||
{
|
||||
"GLD": "SPDR Gold Shares ETF (live data via yfinance)",
|
||||
"GC=F": "Gold Futures (coming soon)",
|
||||
},
|
||||
value=config.underlying,
|
||||
label="Underlying instrument",
|
||||
).classes("w-full")
|
||||
primary_source = ui.select(
|
||||
["yfinance", "ibkr", "alpaca"],
|
||||
value=config.primary_source,
|
||||
|
||||
@@ -24,11 +24,16 @@ except ImportError: # pragma: no cover - optional dependency
|
||||
class DataService:
|
||||
"""Fetches portfolio and market data, using Redis when available."""
|
||||
|
||||
def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None:
|
||||
def __init__(self, cache: CacheService, default_underlying: str = "GLD") -> None:
|
||||
self.cache = cache
|
||||
self.default_symbol = default_symbol
|
||||
self.default_underlying = default_underlying
|
||||
self.gc_f_symbol = "GC=F" # COMEX Gold Futures
|
||||
|
||||
@property
|
||||
def default_symbol(self) -> str:
|
||||
"""Backward compatibility alias for default_underlying."""
|
||||
return self.default_underlying
|
||||
|
||||
async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker = (symbol or self.default_symbol).upper()
|
||||
cache_key = f"portfolio:{ticker}"
|
||||
@@ -51,6 +56,11 @@ class DataService:
|
||||
return portfolio
|
||||
|
||||
async def get_quote(self, symbol: str) -> dict[str, Any]:
|
||||
"""Fetch quote for the given symbol, routing to appropriate data source.
|
||||
|
||||
For GLD: fetches from yfinance (ETF share price)
|
||||
For GC=F: fetches from yfinance (futures price) or returns placeholder
|
||||
"""
|
||||
normalized_symbol = symbol.upper()
|
||||
cache_key = f"quote:{normalized_symbol}"
|
||||
cached = await self.cache.get_json(cache_key)
|
||||
@@ -64,12 +74,17 @@ class DataService:
|
||||
await self.cache.set_json(cache_key, normalized_cached)
|
||||
return normalized_cached
|
||||
|
||||
quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol)
|
||||
# Route based on underlying
|
||||
if normalized_symbol == "GC=F":
|
||||
quote = self._normalize_quote_payload(await self._fetch_gc_futures(), normalized_symbol)
|
||||
else:
|
||||
quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol)
|
||||
|
||||
await self.cache.set_json(cache_key, quote)
|
||||
return quote
|
||||
|
||||
async def get_option_expirations(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker_symbol = (symbol or self.default_symbol).upper()
|
||||
ticker_symbol = (symbol or self.default_underlying).upper()
|
||||
cache_key = f"options:{ticker_symbol}:expirations"
|
||||
|
||||
cached = await self.cache.get_json(cache_key)
|
||||
@@ -90,6 +105,18 @@ class DataService:
|
||||
await self.cache.set_json(cache_key, normalized_cached)
|
||||
return normalized_cached
|
||||
|
||||
# GC=F options not yet implemented - return placeholder
|
||||
if ticker_symbol == "GC=F":
|
||||
quote = await self.get_quote(ticker_symbol)
|
||||
payload = self._fallback_option_expirations(
|
||||
ticker_symbol,
|
||||
quote,
|
||||
source="placeholder",
|
||||
error="Options data for GC=F coming soon",
|
||||
)
|
||||
await self.cache.set_json(cache_key, payload)
|
||||
return payload
|
||||
|
||||
quote = await self.get_quote(ticker_symbol)
|
||||
if yf is None:
|
||||
payload = self._fallback_option_expirations(
|
||||
@@ -140,7 +167,7 @@ class DataService:
|
||||
async def get_options_chain_for_expiry(
|
||||
self, symbol: str | None = None, expiry: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
ticker_symbol = (symbol or self.default_symbol).upper()
|
||||
ticker_symbol = (symbol or self.default_underlying).upper()
|
||||
expirations_data = await self.get_option_expirations(ticker_symbol)
|
||||
expirations = list(expirations_data.get("expirations") or [])
|
||||
target_expiry = expiry or (expirations[0] if expirations else None)
|
||||
@@ -180,6 +207,19 @@ class DataService:
|
||||
await self.cache.set_json(cache_key, normalized_cached)
|
||||
return normalized_cached
|
||||
|
||||
# GC=F options not yet implemented - return placeholder
|
||||
if ticker_symbol == "GC=F":
|
||||
payload = self._fallback_options_chain(
|
||||
ticker_symbol,
|
||||
quote,
|
||||
expirations=expirations,
|
||||
selected_expiry=target_expiry,
|
||||
source="placeholder",
|
||||
error="Options data for GC=F coming soon",
|
||||
)
|
||||
await self.cache.set_json(cache_key, payload)
|
||||
return payload
|
||||
|
||||
if yf is None:
|
||||
payload = self._fallback_options_chain(
|
||||
ticker_symbol,
|
||||
|
||||
Reference in New Issue
Block a user