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:
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user