feat: prioritize lazy options loading and live overview wiring

- queue OPS-001 Caddy route for vd1.uncloud.vpn
- lazy-load options expirations/chains per expiry
- wire overview to live quote data and persisted portfolio config
- extend browser test to verify live quote metadata
This commit is contained in:
Bu5hm4nn
2026-03-23 23:23:59 +01:00
parent d51fa05d5a
commit 133908dd36
6 changed files with 320 additions and 121 deletions

View File

@@ -67,8 +67,8 @@ def strategy_catalog() -> list[dict[str, Any]]:
] ]
def quick_recommendations() -> list[dict[str, str]]: def quick_recommendations(portfolio: dict[str, Any] | None = None) -> list[dict[str, str]]:
portfolio = portfolio_snapshot() portfolio = portfolio or portfolio_snapshot()
ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100 ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100
return [ return [
{ {

View File

@@ -11,19 +11,29 @@ from app.services.runtime import get_data_service
@ui.page("/options") @ui.page("/options")
async def options_page() -> None: async def options_page() -> None:
chain_data = await get_data_service().get_options_chain("GLD") data_service = get_data_service()
chain = list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])]) expirations_data = await data_service.get_option_expirations("GLD")
expiries = list(chain_data.get("expirations") or sorted({row["expiry"] for row in chain})) expiries = list(expirations_data.get("expirations") or [])
strike_values = sorted({float(row["strike"]) for row in chain}) default_expiry = expiries[0] if expiries else None
chain_data = await data_service.get_options_chain_for_expiry("GLD", default_expiry)
selected_expiry = {"value": expiries[0] if expiries else None} chain_state = {
strike_range = { "data": chain_data,
"min": strike_values[0] if strike_values else 0.0, "rows": list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])]),
"max": strike_values[-1] if strike_values else 0.0,
} }
selected_expiry = {"value": chain_data.get("selected_expiry") or default_expiry}
selected_strategy = {"value": strategy_catalog()[0]["label"]} selected_strategy = {"value": strategy_catalog()[0]["label"]}
chosen_contracts: list[dict[str, Any]] = [] chosen_contracts: list[dict[str, Any]] = []
def strike_bounds(rows: list[dict[str, Any]]) -> tuple[float, float]:
strike_values = sorted({float(row["strike"]) for row in rows})
if not strike_values:
return 0.0, 0.0
return strike_values[0], strike_values[-1]
initial_min_strike, initial_max_strike = strike_bounds(chain_state["rows"])
strike_range = {"min": initial_min_strike, "max": initial_max_strike}
with dashboard_page( with dashboard_page(
"Options Chain", "Options Chain",
"Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.", "Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.",
@@ -35,34 +45,17 @@ async def options_page() -> None:
): ):
ui.label("Filters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Filters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
expiry_select = ui.select(expiries, value=selected_expiry["value"], label="Expiry").classes("w-full") expiry_select = ui.select(expiries, value=selected_expiry["value"], label="Expiry").classes("w-full")
min_strike = ui.number( min_strike = ui.number("Min strike", value=strike_range["min"], step=5).classes("w-full")
"Min strike", max_strike = ui.number("Max strike", value=strike_range["max"], step=5).classes("w-full")
value=strike_range["min"],
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] 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( strategy_select = ui.select(
[item["label"] for item in strategy_catalog()], [item["label"] for item in strategy_catalog()],
value=selected_strategy["value"], value=selected_strategy["value"],
label="Add to hedge strategy", label="Add to hedge strategy",
).classes("w-full") ).classes("w-full")
source_label = f"Source: {chain_data.get('source', 'unknown')}" source_html = ui.html("").classes("text-xs text-slate-500 dark:text-slate-400")
if chain_data.get("updated_at"): error_html = ui.html("").classes("text-xs text-amber-700 dark:text-amber-300")
source_label += f" · Updated {chain_data['updated_at']}" loading_html = ui.html("").classes("text-xs text-sky-700 dark:text-sky-300")
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( 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" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
@@ -70,15 +63,27 @@ async def options_page() -> None:
chain_table = ui.html("").classes("w-full") chain_table = ui.html("").classes("w-full")
greeks = GreeksTable([]) greeks = GreeksTable([])
quick_add = ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
def sync_status() -> None:
current_data = chain_state["data"]
source_label = f"Source: {current_data.get('source', 'unknown')}"
if current_data.get("updated_at"):
source_label += f" · Updated {current_data['updated_at']}"
source_html.content = source_label
source_html.update()
error_message = current_data.get("error") or expirations_data.get("error")
error_html.content = f"Options data unavailable: {error_message}" if error_message else ""
error_html.update()
def filtered_rows() -> list[dict[str, Any]]: def filtered_rows() -> list[dict[str, Any]]:
if not selected_expiry["value"]:
return []
return [ return [
row row
for row in chain for row in chain_state["rows"]
if row["expiry"] == selected_expiry["value"] if strike_range["min"] <= float(row["strike"]) <= strike_range["max"]
and strike_range["min"] <= float(row["strike"]) <= strike_range["max"]
] ]
def render_selection() -> None: def render_selection() -> None:
@@ -100,10 +105,7 @@ async def options_page() -> None:
chosen_contracts.append(contract) chosen_contracts.append(contract)
render_selection() render_selection()
greeks.set_options(chosen_contracts[-6:]) greeks.set_options(chosen_contracts[-6:])
ui.notify( ui.notify(f"Added {contract['symbol']} to {selected_strategy['value']}", color="positive")
f"Added {contract['symbol']} to {selected_strategy['value']}",
color="positive",
)
def render_chain() -> None: def render_chain() -> None:
rows = filtered_rows() rows = filtered_rows()
@@ -125,7 +127,8 @@ async def options_page() -> None:
</thead> </thead>
<tbody> <tbody>
""" """
+ "".join(f""" + "".join(
f"""
<tr class='border-b border-slate-200 dark:border-slate-800'> <tr class='border-b border-slate-200 dark:border-slate-800'>
<td class='px-4 py-3 font-medium text-slate-900 dark:text-slate-100'>{row['symbol']}</td> <td class='px-4 py-3 font-medium text-slate-900 dark:text-slate-100'>{row['symbol']}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td>
@@ -136,7 +139,9 @@ async def options_page() -> None:
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'{float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f}</td> <td class='px-4 py-3 text-slate-600 dark:text-slate-300'{float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f}</td>
<td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td> <td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td>
</tr> </tr>
""" for row in rows) """
for row in rows
)
+ ( + (
"" ""
if rows if rows
@@ -162,32 +167,48 @@ async def options_page() -> None:
).props("outline color=primary") ).props("outline color=primary")
greeks.set_options(rows[:6]) greeks.set_options(rows[:6])
quick_add = ui.card().classes( async def load_expiry_chain(expiry: str | None) -> None:
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" selected_expiry["value"] = expiry
) loading_html.content = "Loading selected expiry…" if expiry else ""
loading_html.update()
next_chain = await data_service.get_options_chain_for_expiry("GLD", expiry)
chain_state["data"] = next_chain
chain_state["rows"] = list(next_chain.get("rows") or [*next_chain.get("calls", []), *next_chain.get("puts", [])])
min_value, max_value = strike_bounds(chain_state["rows"])
strike_range["min"] = min_value
strike_range["max"] = max_value
min_strike.value = min_value
max_strike.value = max_value
loading_html.content = ""
loading_html.update()
sync_status()
render_chain()
def update_filters() -> None: def update_filters() -> None:
selected_expiry["value"] = expiry_select.value
strike_range["min"] = float(min_strike.value or 0.0) strike_range["min"] = float(min_strike.value or 0.0)
strike_range["max"] = float(max_strike.value or 0.0) strike_range["max"] = float(max_strike.value or 0.0)
if strike_range["min"] > strike_range["max"]: if strike_range["min"] > strike_range["max"]:
strike_range["min"], strike_range["max"] = ( strike_range["min"], strike_range["max"] = (strike_range["max"], strike_range["min"])
strike_range["max"],
strike_range["min"],
)
min_strike.value = strike_range["min"] min_strike.value = strike_range["min"]
max_strike.value = strike_range["max"] max_strike.value = strike_range["max"]
render_chain() render_chain()
expiry_select.on_value_change(lambda _: update_filters()) async def on_expiry_change(event: Any) -> None:
await load_expiry_chain(event.value)
expiry_select.on_value_change(on_expiry_change)
min_strike.on_value_change(lambda _: update_filters()) min_strike.on_value_change(lambda _: update_filters())
max_strike.on_value_change(lambda _: update_filters()) max_strike.on_value_change(lambda _: update_filters())
def on_strategy_change(event) -> None: def on_strategy_change(event: Any) -> None:
selected_strategy["value"] = event.value # type: ignore[assignment] selected_strategy["value"] = event.value
render_selection() render_selection()
strategy_select.on_value_change(on_strategy_change) strategy_select.on_value_change(on_strategy_change)
sync_status()
render_selection() render_selection()
render_chain() render_chain()

View File

@@ -1,48 +1,98 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from nicegui import ui from nicegui import ui
from app.components import PortfolioOverview from app.components import PortfolioOverview
from app.pages.common import ( from app.models.portfolio import PortfolioConfig, get_portfolio_repository
dashboard_page, from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
portfolio_snapshot, from app.services.runtime import get_data_service
quick_recommendations,
recommendation_style, _REFERENCE_SPOT_PRICE = 215.0
strategy_catalog, _DEFAULT_CASH_BUFFER = 18_500.0
)
def _format_timestamp(value: str | None) -> str:
if not value:
return "Unavailable"
try:
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return value
return timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
def _build_live_portfolio(config: PortfolioConfig, quote: dict[str, object]) -> dict[str, float | str]:
spot_price = float(quote.get("price", _REFERENCE_SPOT_PRICE))
configured_gold_value = float(config.gold_value)
estimated_units = configured_gold_value / _REFERENCE_SPOT_PRICE if _REFERENCE_SPOT_PRICE > 0 else 0.0
live_gold_value = estimated_units * spot_price
loan_amount = float(config.loan_amount)
margin_call_ltv = float(config.margin_threshold)
ltv_ratio = loan_amount / live_gold_value if live_gold_value > 0 else 0.0
return {
"spot_price": spot_price,
"gold_units": estimated_units,
"gold_value": live_gold_value,
"loan_amount": loan_amount,
"ltv_ratio": ltv_ratio,
"net_equity": live_gold_value - loan_amount,
"margin_call_ltv": margin_call_ltv,
"margin_call_price": loan_amount / (margin_call_ltv * estimated_units) if estimated_units > 0 else 0.0,
"cash_buffer": max(live_gold_value - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER,
"hedge_budget": float(config.monthly_budget),
"quote_source": str(quote.get("source", "unknown")),
"quote_updated_at": str(quote.get("updated_at", "")),
}
@ui.page("/") @ui.page("/")
@ui.page("/overview") @ui.page("/overview")
def overview_page() -> None: async def overview_page() -> None:
portfolio = portfolio_snapshot() config = get_portfolio_repository().load()
data_service = get_data_service()
symbol = data_service.default_symbol
quote = await data_service.get_quote(symbol)
portfolio = _build_live_portfolio(config, quote)
quote_status = (
f"Live quote source: {portfolio['quote_source']} · "
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
)
with dashboard_page( with dashboard_page(
"Overview", "Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
"overview", "overview",
): ):
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}"
).classes("text-sm text-slate-500 dark:text-slate-400")
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
summary_cards = [ summary_cards = [
( (
"Spot Price", "Spot Price",
f"${portfolio['spot_price']:,.2f}", f"${portfolio['spot_price']:,.2f}",
"GLD reference price", f"{symbol} live quote via {portfolio['quote_source']}",
), ),
( (
"Margin Call Price", "Margin Call Price",
f"${portfolio['margin_call_price']:,.2f}", f"${portfolio['margin_call_price']:,.2f}",
"Implied trigger level", "Implied trigger level from persisted portfolio settings",
), ),
( (
"Cash Buffer", "Cash Buffer",
f"${portfolio['cash_buffer']:,.0f}", f"${portfolio['cash_buffer']:,.0f}",
"Available liquidity", "Base liquidity plus unrealized gain cushion vs configured baseline",
), ),
( (
"Hedge Budget", "Hedge Budget",
f"${portfolio['hedge_budget']:,.0f}", f"${portfolio['hedge_budget']:,.0f}",
"Approved premium budget", "Monthly budget from saved settings",
), ),
] ]
for title, value, caption in summary_cards: for title, value, caption in summary_cards:
@@ -53,7 +103,7 @@ def overview_page() -> None:
ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400")
portfolio_view = PortfolioOverview(margin_call_ltv=portfolio["margin_call_ltv"]) portfolio_view = PortfolioOverview(margin_call_ltv=float(portfolio["margin_call_ltv"]))
portfolio_view.update(portfolio) portfolio_view.update(portfolio)
with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.row().classes("w-full gap-6 max-lg:flex-col"):
@@ -62,15 +112,15 @@ def overview_page() -> None:
): ):
with ui.row().classes("w-full items-center justify-between"): with ui.row().classes("w-full items-center justify-between"):
ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Threshold {portfolio['margin_call_ltv'] * 100:.0f}%").classes( ui.label(f"Threshold {float(portfolio['margin_call_ltv']) * 100:.0f}%").classes(
"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" "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"
) )
ui.linear_progress( ui.linear_progress(
value=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], value=float(portfolio["ltv_ratio"]) / max(float(portfolio["margin_call_ltv"]), 0.01),
show_value=False, show_value=False,
).props("color=warning track-color=grey-3 rounded") ).props("color=warning track-color=grey-3 rounded")
ui.label( ui.label(
f"Current LTV is {portfolio['ltv_ratio'] * 100:.1f}% with a margin buffer of {(portfolio['margin_call_ltv'] - portfolio['ltv_ratio']) * 100:.1f} percentage points." f"Current LTV is {float(portfolio['ltv_ratio']) * 100:.1f}% with a margin buffer of {(float(portfolio['margin_call_ltv']) - float(portfolio['ltv_ratio'])) * 100:.1f} percentage points."
).classes("text-sm text-slate-600 dark:text-slate-300") ).classes("text-sm text-slate-600 dark:text-slate-300")
ui.label( ui.label(
"Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required." "Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required."
@@ -93,7 +143,7 @@ def overview_page() -> None:
ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100") ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"): with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"):
for rec in quick_recommendations(): for rec in quick_recommendations(portfolio):
with ui.card().classes(f"rounded-2xl border shadow-sm {recommendation_style(rec['tone'])}"): with ui.card().classes(f"rounded-2xl border shadow-sm {recommendation_style(rec['tone'])}"):
ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100") ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100")
ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300") ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300")

View File

@@ -57,9 +57,9 @@ class DataService:
await self.cache.set_json(cache_key, quote) await self.cache.set_json(cache_key, quote)
return quote return quote
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]: 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_symbol).upper()
cache_key = f"options:{ticker_symbol}" cache_key = f"options:{ticker_symbol}:expirations"
cached = await self.cache.get_json(cache_key) cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict): if cached and isinstance(cached, dict):
@@ -67,71 +67,141 @@ class DataService:
quote = await self.get_quote(ticker_symbol) quote = await self.get_quote(ticker_symbol)
if yf is None: if yf is None:
options_chain = self._fallback_options_chain(ticker_symbol, quote, source="fallback") payload = self._fallback_option_expirations(
await self.cache.set_json(cache_key, options_chain) ticker_symbol,
return options_chain quote,
source="fallback",
error="yfinance is not installed",
)
await self.cache.set_json(cache_key, payload)
return payload
try: try:
ticker = yf.Ticker(ticker_symbol) ticker = yf.Ticker(ticker_symbol)
expirations = await asyncio.to_thread(lambda: list(ticker.options or [])) expirations = await asyncio.to_thread(lambda: list(ticker.options or []))
if not expirations: if not expirations:
options_chain = self._fallback_options_chain( payload = self._fallback_option_expirations(
ticker_symbol, ticker_symbol,
quote, quote,
source="fallback", source="fallback",
error="No option expirations returned by yfinance", error="No option expirations returned by yfinance",
) )
await self.cache.set_json(cache_key, options_chain) await self.cache.set_json(cache_key, payload)
return options_chain return payload
# Limit initial load to the nearest expirations so the page can render quickly. payload = {
expirations = expirations[:3]
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, "symbol": ticker_symbol,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(UTC).isoformat(),
"expirations": expirations, "expirations": expirations,
"calls": calls,
"puts": puts,
"rows": sorted(calls + puts, key=lambda row: (row["expiry"], row["strike"], row["type"])),
"underlying_price": quote["price"], "underlying_price": quote["price"],
"source": "yfinance", "source": "yfinance",
} }
await self.cache.set_json(cache_key, options_chain) await self.cache.set_json(cache_key, payload)
return options_chain return payload
except Exception as exc: # pragma: no cover - network dependent except Exception as exc: # pragma: no cover - network dependent
logger.warning("Failed to fetch options chain for %s from yfinance: %s", ticker_symbol, exc) logger.warning("Failed to fetch option expirations for %s from yfinance: %s", ticker_symbol, exc)
options_chain = self._fallback_options_chain( payload = self._fallback_option_expirations(
ticker_symbol, ticker_symbol,
quote, quote,
source="fallback", source="fallback",
error=str(exc), error=str(exc),
) )
await self.cache.set_json(cache_key, options_chain) await self.cache.set_json(cache_key, payload)
return options_chain return payload
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()
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)
quote = await self.get_quote(ticker_symbol)
if not target_expiry:
return self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=None,
source=expirations_data.get("source", quote.get("source", "fallback")),
error=expirations_data.get("error"),
)
cache_key = f"options:{ticker_symbol}:{target_expiry}"
cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict):
return cached
if yf is None:
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="fallback",
error="yfinance is not installed",
)
await self.cache.set_json(cache_key, payload)
return payload
try:
ticker = yf.Ticker(ticker_symbol)
chain = await asyncio.to_thread(ticker.option_chain, target_expiry)
calls = self._normalize_option_rows(chain.calls, ticker_symbol, target_expiry, "call")
puts = self._normalize_option_rows(chain.puts, ticker_symbol, target_expiry, "put")
if not calls and not puts:
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="fallback",
error="No option contracts returned by yfinance",
)
await self.cache.set_json(cache_key, payload)
return payload
payload = {
"symbol": ticker_symbol,
"selected_expiry": target_expiry,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": expirations,
"calls": calls,
"puts": puts,
"rows": sorted(calls + puts, key=lambda row: (row["strike"], row["type"])),
"underlying_price": quote["price"],
"source": "yfinance",
}
await self.cache.set_json(cache_key, payload)
return payload
except Exception as exc: # pragma: no cover - network dependent
logger.warning("Failed to fetch options chain for %s %s from yfinance: %s", ticker_symbol, target_expiry, exc)
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="fallback",
error=str(exc),
)
await self.cache.set_json(cache_key, payload)
return payload
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper()
expirations_data = await self.get_option_expirations(ticker_symbol)
expirations = list(expirations_data.get("expirations") or [])
if not expirations:
quote = await self.get_quote(ticker_symbol)
return self._fallback_options_chain(
ticker_symbol,
quote,
expirations=[],
selected_expiry=None,
source=expirations_data.get("source", quote.get("source", "fallback")),
error=expirations_data.get("error"),
)
return await self.get_options_chain_for_expiry(ticker_symbol, expirations[0])
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()
@@ -184,7 +254,7 @@ class DataService:
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc) logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
return self._fallback_quote(symbol, source="fallback") return self._fallback_quote(symbol, source="fallback")
def _fallback_options_chain( def _fallback_option_expirations(
self, self,
symbol: str, symbol: str,
quote: dict[str, Any], quote: dict[str, Any],
@@ -192,10 +262,32 @@ class DataService:
source: str, source: str,
error: str | None = None, error: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
options_chain = { payload = {
"symbol": symbol, "symbol": symbol,
"updated_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(UTC).isoformat(),
"expirations": [], "expirations": [],
"underlying_price": quote["price"],
"source": source,
}
if error:
payload["error"] = error
return payload
def _fallback_options_chain(
self,
symbol: str,
quote: dict[str, Any],
*,
expirations: list[str],
selected_expiry: str | None,
source: str,
error: str | None = None,
) -> dict[str, Any]:
options_chain = {
"symbol": symbol,
"selected_expiry": selected_expiry,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": expirations,
"calls": [], "calls": [],
"puts": [], "puts": [],
"rows": [], "rows": [],

View File

@@ -237,11 +237,46 @@ DATA-001 (Price Feed)
--- ---
## Fast Follow-up Backlog
### DATA-002A: Lazy Options Loading [P0, S] **[depends: DATA-002]**
**As a** trader, **I want** the options page to render immediately **so that** it feels responsive and usable.
**Acceptance Criteria:**
- Initial page load fetches only expirations plus one default expiry chain
- Changing expiry fetches that expiry on demand
- Browser test verifies `/options` becomes visible quickly
- No visible 500/runtime error during page load
**Dependencies:** DATA-002
### DATA-001A: Live Overview Price Wiring [P0, S] **[depends: DATA-001, PORT-001]**
**As a** portfolio manager, **I want** the overview cards to use live quote data **so that** the displayed spot/LTV values are trustworthy.
**Acceptance Criteria:**
- Overview page uses live quote from service instead of hardcoded `215.0`
- Display source and last updated timestamp
- Margin call / LTV calculations use configured portfolio values
- Browser test verifies overview renders with live data metadata
**Dependencies:** DATA-001, PORT-001
### OPS-001: Caddy Route for Production Dashboard [P1, S] **[depends: deploy-stable]**
**As a** VPN user, **I want** to reach the deployed dashboard at `vd1.uncloud.vpn` **so that** I can access it without SSH port forwarding.
**Acceptance Criteria:**
- Caddy route proxies `vd1.uncloud.vpn` to local deployment container
- Route works over the VPN only
- Health check succeeds through Caddy
- Deployment docs include the route and where it lives
**Dependencies:** stable deployed app on VPS
## Implementation Priority Queue ## Implementation Priority Queue
1. **DATA-001** - Unblock all other features 1. **DATA-002A** - Fix options UX/performance regression
2. **PORT-001** - Enable user-specific calculations 2. **DATA-001A** - Remove misleading mock overview price
3. **DATA-002** - Core options data 3. **OPS-001** - Add Caddy route once app behavior is stable
4. **DATA-003** - Risk metrics 4. **DATA-003** - Risk metrics
5. **PORT-002** - Risk management safety 5. **PORT-002** - Risk management safety
6. **EXEC-001** - Core user workflow 6. **EXEC-001** - Core user workflow

View File

@@ -18,6 +18,7 @@ def test_homepage_and_options_page_render() -> None:
expect(page).to_have_title("NiceGUI") expect(page).to_have_title("NiceGUI")
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000) expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000) expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000)
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True) page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000) page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)