diff --git a/app/main.py b/app/main.py
index 2957a6e..355e6f8 100644
--- a/app/main.py
+++ b/app/main.py
@@ -18,6 +18,7 @@ import app.pages # noqa: F401
from app.api.routes import router as api_router
from app.services.cache import CacheService
from app.services.data_service import DataService
+from app.services.runtime import set_data_service
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
@@ -110,6 +111,7 @@ async def lifespan(app: FastAPI):
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)
+ set_data_service(app.state.data_service)
app.state.ws_manager = ConnectionManager()
app.state.publisher_task = asyncio.create_task(publish_updates(app))
logger.info("Application startup complete")
diff --git a/app/models/portfolio.py b/app/models/portfolio.py
index 842d9fb..272c2bb 100644
--- a/app/models/portfolio.py
+++ b/app/models/portfolio.py
@@ -1,14 +1,66 @@
-"""Portfolio configuration models with validation and persistence."""
+"""Portfolio configuration and domain portfolio models."""
from __future__ import annotations
import json
-import os
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from pathlib import Path
from typing import Any
+@dataclass(frozen=True)
+class LombardPortfolio:
+ """Lombard loan portfolio backed by physical gold."""
+
+ gold_ounces: float
+ gold_price_per_ounce: float
+ loan_amount: float
+ initial_ltv: float
+ margin_call_ltv: float
+
+ def __post_init__(self) -> None:
+ if self.gold_ounces <= 0:
+ raise ValueError("gold_ounces must be positive")
+ if self.gold_price_per_ounce <= 0:
+ raise ValueError("gold_price_per_ounce must be positive")
+ if self.loan_amount < 0:
+ raise ValueError("loan_amount must be non-negative")
+ if not 0 < self.initial_ltv < 1:
+ raise ValueError("initial_ltv must be between 0 and 1")
+ if not 0 < self.margin_call_ltv < 1:
+ raise ValueError("margin_call_ltv must be between 0 and 1")
+ if self.initial_ltv > self.margin_call_ltv:
+ raise ValueError("initial_ltv cannot exceed margin_call_ltv")
+ if self.loan_amount > self.gold_value:
+ raise ValueError("loan_amount cannot exceed current gold value")
+
+ @property
+ def gold_value(self) -> float:
+ return self.gold_ounces * self.gold_price_per_ounce
+
+ @property
+ def current_ltv(self) -> float:
+ return self.loan_amount / self.gold_value
+
+ @property
+ def net_equity(self) -> float:
+ return self.gold_value - self.loan_amount
+
+ def gold_value_at_price(self, gold_price_per_ounce: float) -> float:
+ if gold_price_per_ounce <= 0:
+ raise ValueError("gold_price_per_ounce must be positive")
+ return self.gold_ounces * gold_price_per_ounce
+
+ def ltv_at_price(self, gold_price_per_ounce: float) -> float:
+ return self.loan_amount / self.gold_value_at_price(gold_price_per_ounce)
+
+ def net_equity_at_price(self, gold_price_per_ounce: float) -> float:
+ return self.gold_value_at_price(gold_price_per_ounce) - self.loan_amount
+
+ def margin_call_price(self) -> float:
+ return self.loan_amount / (self.margin_call_ltv * self.gold_ounces)
+
+
@dataclass
class PortfolioConfig:
"""User portfolio configuration with validation.
diff --git a/app/pages/options.py b/app/pages/options.py
index 616da0e..5683f37 100644
--- a/app/pages/options.py
+++ b/app/pages/options.py
@@ -5,16 +5,22 @@ from typing import Any
from nicegui import ui
from app.components import GreeksTable
-from app.pages.common import dashboard_page, option_chain, strategy_catalog
+from app.pages.common import dashboard_page, strategy_catalog
+from app.services.runtime import get_data_service
@ui.page("/options")
-def options_page() -> None:
- chain = option_chain()
- expiries = sorted({row["expiry"] for row in chain})
- strike_values = sorted({row["strike"] for row in chain})
- selected_expiry = {"value": expiries[0]}
- strike_range = {"min": strike_values[0], "max": strike_values[-1]}
+async def options_page() -> None:
+ chain_data = await get_data_service().get_options_chain("GLD")
+ chain = list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])])
+ expiries = list(chain_data.get("expirations") or sorted({row["expiry"] for row in chain}))
+ strike_values = sorted({float(row["strike"]) for row in chain})
+
+ selected_expiry = {"value": expiries[0] if expiries else None}
+ strike_range = {
+ "min": strike_values[0] if strike_values else 0.0,
+ "max": strike_values[-1] if strike_values else 0.0,
+ }
selected_strategy = {"value": strategy_catalog()[0]["label"]}
chosen_contracts: list[dict[str, Any]] = []
@@ -32,15 +38,15 @@ def options_page() -> None:
min_strike = ui.number(
"Min strike",
value=strike_range["min"],
- min=strike_values[0],
- max=strike_values[-1],
+ min=strike_values[0] if strike_values else 0.0,
+ max=strike_values[-1] if strike_values else 0.0,
step=5,
).classes("w-full")
max_strike = ui.number(
"Max strike",
value=strike_range["max"],
- min=strike_values[0],
- max=strike_values[-1],
+ min=strike_values[0] if strike_values else 0.0,
+ max=strike_values[-1] if strike_values else 0.0,
step=5,
).classes("w-full")
strategy_select = ui.select(
@@ -49,6 +55,15 @@ def options_page() -> None:
label="Add to hedge strategy",
).classes("w-full")
+ source_label = f"Source: {chain_data.get('source', 'unknown')}"
+ if chain_data.get("updated_at"):
+ source_label += f" · Updated {chain_data['updated_at']}"
+ ui.label(source_label).classes("text-xs text-slate-500 dark:text-slate-400")
+ if chain_data.get("error"):
+ ui.label(f"Options data unavailable: {chain_data['error']}").classes(
+ "text-xs text-amber-700 dark:text-amber-300"
+ )
+
selection_card = ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
@@ -56,12 +71,14 @@ def options_page() -> None:
chain_table = ui.html("").classes("w-full")
greeks = GreeksTable([])
- def filtered_rows() -> list[dict]:
+ def filtered_rows() -> list[dict[str, Any]]:
+ if not selected_expiry["value"]:
+ return []
return [
row
for row in chain
if row["expiry"] == selected_expiry["value"]
- and strike_range["min"] <= row["strike"] <= strike_range["max"]
+ and strike_range["min"] <= float(row["strike"]) <= strike_range["max"]
]
def render_selection() -> None:
@@ -76,10 +93,10 @@ def options_page() -> None:
return
for contract in chosen_contracts[-3:]:
ui.label(
- f"{contract['symbol']} · premium ${contract['premium']:.2f} · Δ {contract['delta']:+.3f}"
+ f"{contract['symbol']} · premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}"
).classes("text-sm text-slate-600 dark:text-slate-300")
- def add_to_strategy(contract: dict) -> None:
+ def add_to_strategy(contract: dict[str, Any]) -> None:
chosen_contracts.append(contract)
render_selection()
greeks.set_options(chosen_contracts[-6:])
@@ -100,6 +117,8 @@ def options_page() -> None:
Type |
Strike |
Bid / Ask |
+ Last |
+ IV |
Greeks |
Action |
@@ -110,16 +129,18 @@ def options_page() -> None:
| {row['symbol']} |
{row['type'].upper()} |
- ${row['strike']:.2f} |
- ${row['bid']:.2f} / ${row['ask']:.2f} |
- Δ {row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.3f} |
+ ${float(row['strike']):.2f} |
+ ${float(row['bid']):.2f} / ${float(row['ask']):.2f} |
+ ${float(row.get('lastPrice', row.get('premium', 0.0))):.2f} |
+ {float(row.get('impliedVolatility', 0.0)):.1%} |
+ Δ {float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f} |
Use quick-add buttons below |
""" for row in rows)
+ (
""
if rows
- else "| No contracts match the current filter. |
"
+ else "| No contracts match the current filter. |
"
)
+ """
@@ -136,7 +157,7 @@ def options_page() -> None:
with ui.row().classes("w-full gap-2 max-sm:flex-col"):
for row in rows[:6]:
ui.button(
- f"Add {row['type'].upper()} {row['strike']:.0f}",
+ f"Add {row['type'].upper()} {float(row['strike']):.0f}",
on_click=lambda _, contract=row: add_to_strategy(contract),
).props("outline color=primary")
greeks.set_options(rows[:6])
@@ -147,8 +168,8 @@ def options_page() -> None:
def update_filters() -> None:
selected_expiry["value"] = expiry_select.value
- strike_range["min"] = float(min_strike.value)
- strike_range["max"] = float(max_strike.value)
+ strike_range["min"] = float(min_strike.value or 0.0)
+ strike_range["max"] = float(max_strike.value or 0.0)
if strike_range["min"] > strike_range["max"]:
strike_range["min"], strike_range["max"] = (
strike_range["max"],
@@ -161,6 +182,7 @@ def options_page() -> None:
expiry_select.on_value_change(lambda _: update_filters())
min_strike.on_value_change(lambda _: update_filters())
max_strike.on_value_change(lambda _: update_filters())
+
def on_strategy_change(event) -> None:
selected_strategy["value"] = event.value # type: ignore[assignment]
render_selection()
diff --git a/app/services/data_service.py b/app/services/data_service.py
index 1f8f1bf..65e5225 100644
--- a/app/services/data_service.py
+++ b/app/services/data_service.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
+import math
from datetime import UTC, datetime
from typing import Any
@@ -57,46 +58,77 @@ class DataService:
return quote
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
- ticker = (symbol or self.default_symbol).upper()
- cache_key = f"options:{ticker}"
+ ticker_symbol = (symbol or self.default_symbol).upper()
+ cache_key = f"options:{ticker_symbol}"
cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict):
return cached
- quote = await self.get_quote(ticker)
- base_price = quote["price"]
- options_chain = {
- "symbol": ticker,
- "updated_at": datetime.now(UTC).isoformat(),
- "calls": [
- {
- "strike": round(base_price * 1.05, 2),
- "premium": round(base_price * 0.03, 2),
- "expiry": "2026-06-19",
- },
- {
- "strike": round(base_price * 1.10, 2),
- "premium": round(base_price * 0.02, 2),
- "expiry": "2026-09-18",
- },
- ],
- "puts": [
- {
- "strike": round(base_price * 0.95, 2),
- "premium": round(base_price * 0.028, 2),
- "expiry": "2026-06-19",
- },
- {
- "strike": round(base_price * 0.90, 2),
- "premium": round(base_price * 0.018, 2),
- "expiry": "2026-09-18",
- },
- ],
- "source": quote["source"],
- }
- await self.cache.set_json(cache_key, options_chain)
- return options_chain
+ quote = await self.get_quote(ticker_symbol)
+ if yf is None:
+ options_chain = self._fallback_options_chain(ticker_symbol, quote, source="fallback")
+ await self.cache.set_json(cache_key, options_chain)
+ return options_chain
+
+ try:
+ ticker = yf.Ticker(ticker_symbol)
+ expirations = await asyncio.to_thread(lambda: list(ticker.options or []))
+ if not expirations:
+ options_chain = self._fallback_options_chain(
+ ticker_symbol,
+ quote,
+ source="fallback",
+ error="No option expirations returned by yfinance",
+ )
+ await self.cache.set_json(cache_key, options_chain)
+ return options_chain
+
+ calls: list[dict[str, Any]] = []
+ puts: list[dict[str, Any]] = []
+
+ for expiry in expirations:
+ try:
+ chain = await asyncio.to_thread(ticker.option_chain, expiry)
+ except Exception as exc: # pragma: no cover - network dependent
+ logger.warning("Failed to fetch option chain for %s %s: %s", ticker_symbol, expiry, exc)
+ continue
+
+ calls.extend(self._normalize_option_rows(chain.calls, ticker_symbol, expiry, "call"))
+ puts.extend(self._normalize_option_rows(chain.puts, ticker_symbol, expiry, "put"))
+
+ if not calls and not puts:
+ options_chain = self._fallback_options_chain(
+ ticker_symbol,
+ quote,
+ source="fallback",
+ error="No option contracts returned by yfinance",
+ )
+ await self.cache.set_json(cache_key, options_chain)
+ return options_chain
+
+ options_chain = {
+ "symbol": ticker_symbol,
+ "updated_at": datetime.now(UTC).isoformat(),
+ "expirations": expirations,
+ "calls": calls,
+ "puts": puts,
+ "rows": sorted(calls + puts, key=lambda row: (row["expiry"], row["strike"], row["type"])),
+ "underlying_price": quote["price"],
+ "source": "yfinance",
+ }
+ await self.cache.set_json(cache_key, options_chain)
+ return options_chain
+ except Exception as exc: # pragma: no cover - network dependent
+ logger.warning("Failed to fetch options chain for %s from yfinance: %s", ticker_symbol, exc)
+ options_chain = self._fallback_options_chain(
+ ticker_symbol,
+ quote,
+ source="fallback",
+ error=str(exc),
+ )
+ await self.cache.set_json(cache_key, options_chain)
+ return options_chain
async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper()
@@ -149,6 +181,81 @@ class DataService:
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
return self._fallback_quote(symbol, source="fallback")
+ def _fallback_options_chain(
+ self,
+ symbol: str,
+ quote: dict[str, Any],
+ *,
+ source: str,
+ error: str | None = None,
+ ) -> dict[str, Any]:
+ options_chain = {
+ "symbol": symbol,
+ "updated_at": datetime.now(UTC).isoformat(),
+ "expirations": [],
+ "calls": [],
+ "puts": [],
+ "rows": [],
+ "underlying_price": quote["price"],
+ "source": source,
+ }
+ if error:
+ options_chain["error"] = error
+ return options_chain
+
+ def _normalize_option_rows(self, frame: Any, symbol: str, expiry: str, option_type: str) -> list[dict[str, Any]]:
+ if frame is None or getattr(frame, "empty", True):
+ return []
+
+ rows: list[dict[str, Any]] = []
+ for item in frame.to_dict(orient="records"):
+ strike = self._safe_float(item.get("strike"))
+ if strike <= 0:
+ continue
+
+ bid = self._safe_float(item.get("bid"))
+ ask = self._safe_float(item.get("ask"))
+ last_price = self._safe_float(item.get("lastPrice"))
+ implied_volatility = self._safe_float(item.get("impliedVolatility"))
+ contract_symbol = str(item.get("contractSymbol") or "").strip()
+
+ rows.append(
+ {
+ "contractSymbol": contract_symbol,
+ "symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}",
+ "strike": strike,
+ "bid": bid,
+ "ask": ask,
+ "premium": last_price or self._midpoint(bid, ask),
+ "lastPrice": last_price,
+ "impliedVolatility": implied_volatility,
+ "expiry": expiry,
+ "type": option_type,
+ "openInterest": int(self._safe_float(item.get("openInterest"))),
+ "volume": int(self._safe_float(item.get("volume"))),
+ "delta": 0.0,
+ "gamma": 0.0,
+ "theta": 0.0,
+ "vega": 0.0,
+ "rho": 0.0,
+ }
+ )
+ return rows
+
+ @staticmethod
+ def _safe_float(value: Any) -> float:
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return 0.0
+ return 0.0 if math.isnan(result) else result
+
+ @staticmethod
+ def _midpoint(bid: float, ask: float) -> float:
+ if bid > 0 and ask > 0:
+ return round((bid + ask) / 2, 4)
+ return max(bid, ask, 0.0)
+
@staticmethod
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
return {
diff --git a/app/services/runtime.py b/app/services/runtime.py
new file mode 100644
index 0000000..0f44d6e
--- /dev/null
+++ b/app/services/runtime.py
@@ -0,0 +1,18 @@
+"""Runtime service registry for UI pages and background tasks."""
+
+from __future__ import annotations
+
+from app.services.data_service import DataService
+
+_data_service: DataService | None = None
+
+
+def set_data_service(service: DataService) -> None:
+ global _data_service
+ _data_service = service
+
+
+def get_data_service() -> DataService:
+ if _data_service is None:
+ raise RuntimeError("DataService has not been initialized")
+ return _data_service