Files
vault-dash/app/pages/options.py
Bu5hm4nn 6bcf78e5df style: format UI files and remove lint excludes
- Remove app/components/ and app/pages/ from ruff/black excludes
- Pre-commit reformatted multi-line strings for consistency
- All files now follow the same code style
2026-04-01 13:55:55 +02:00

227 lines
12 KiB
Python

from __future__ import annotations
from typing import Any
from nicegui import ui
from app.components import GreeksTable
from app.pages.common import dashboard_page, split_page_panes, strategy_catalog
from app.services.runtime import get_data_service
@ui.page("/options")
async def options_page() -> None:
data_service = get_data_service()
expirations_data = await data_service.get_option_expirations("GLD")
expiries = list(expirations_data.get("expirations") or [])
default_expiry = expiries[0] if expiries else None
chain_data = await data_service.get_options_chain_for_expiry("GLD", default_expiry)
chain_state = {
"data": chain_data,
"rows": list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])]),
}
selected_expiry = {"value": chain_data.get("selected_expiry") or default_expiry}
selected_strategy = {"value": strategy_catalog()[0]["label"]}
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(
"Options Chain",
"Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.",
"options",
):
left_pane, right_pane = split_page_panes(
left_testid="options-left-pane",
right_testid="options-right-pane",
)
with left_pane:
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
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")
min_strike = ui.number("Min strike", value=strike_range["min"], step=5).classes("w-full")
max_strike = ui.number("Max strike", value=strike_range["max"], step=5).classes("w-full")
strategy_select = ui.select(
[item["label"] for item in strategy_catalog()],
value=selected_strategy["value"],
label="Add to hedge strategy",
).classes("w-full")
source_html = ui.html("").classes("text-xs text-slate-500 dark:text-slate-400")
error_html = ui.html("").classes("text-xs text-amber-700 dark:text-amber-300")
loading_html = ui.html("").classes("text-xs text-sky-700 dark:text-sky-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"
)
with right_pane:
chain_table = ui.html("").classes("w-full")
with ui.row().classes("w-full gap-6 max-xl:flex-col"):
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]]:
return [
row for row in chain_state["rows"] if strike_range["min"] <= float(row["strike"]) <= strike_range["max"]
]
def render_selection() -> None:
selection_card.clear()
with selection_card:
ui.label("Strategy Integration").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Target strategy: {selected_strategy['value']}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
if not chosen_contracts:
ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400")
return
with ui.column().classes("w-full gap-3"):
for contract in chosen_contracts[-3:]:
with ui.card().classes(
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label(contract["symbol"]).classes("font-semibold text-slate-900 dark:text-slate-100")
ui.label(
f"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[str, Any]) -> None:
chosen_contracts.append(contract)
render_selection()
greeks.set_options(chosen_contracts[-6:])
ui.notify(f"Added {contract['symbol']} to {selected_strategy['value']}", color="positive")
def render_chain() -> None:
rows = filtered_rows()
chain_table.content = (
"""
<div class='overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900'>
<table class='min-w-full'>
<thead class='bg-slate-100 dark:bg-slate-800'>
<tr>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Contract</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Type</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Strike</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Bid / Ask</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Last</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>IV</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Greeks</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Action</th>
</tr>
</thead>
<tbody>
"""
+ "".join(
f"""
<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 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row['strike']):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row['bid']):.2f} / ${float(row['ask']):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${float(row.get('lastPrice', row.get('premium', 0.0))):.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{float(row.get('impliedVolatility', 0.0)):.1%}</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} · V {float(row.get('vega', 0.0)):.3f}</td>
<td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td>
</tr>
"""
for row in rows
)
+ (
""
if rows
else "<tr><td colspan='8' class='px-4 py-6 text-center text-slate-500 dark:text-slate-400'>No contracts match the current filter.</td></tr>"
)
+ """
</tbody>
</table>
</div>
"""
)
chain_table.update()
quick_add.clear()
with quick_add:
ui.label("Quick add to hedge").classes(
"text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
)
with ui.row().classes("w-full gap-2 max-sm:flex-col"):
for row in rows[:6]:
ui.button(
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(chosen_contracts[-6:] if chosen_contracts else rows[:6])
async def load_expiry_chain(expiry: str | None) -> None:
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:
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"], strike_range["min"])
min_strike.value = strike_range["min"]
max_strike.value = strike_range["max"]
render_chain()
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())
max_strike.on_value_change(lambda _: update_filters())
def on_strategy_change(event: Any) -> None:
selected_strategy["value"] = event.value
render_selection()
strategy_select.on_value_change(on_strategy_change)
sync_status()
render_selection()
render_chain()