Initial commit: Vault Dashboard for options hedging

- FastAPI + NiceGUI web application
- QuantLib-based Black-Scholes pricing with Greeks
- Protective put, laddered, and LEAPS strategies
- Real-time WebSocket updates
- TradingView-style charts via Lightweight-Charts
- Docker containerization
- GitLab CI/CD pipeline for VPS deployment
- VPN-only access configuration
This commit is contained in:
Bu5hm4nn
2026-03-21 19:21:40 +01:00
commit 00a68bc767
63 changed files with 6239 additions and 0 deletions

3
app/pages/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from . import hedge, options, overview, settings
__all__ = ["overview", "hedge", "options", "settings"]

203
app/pages/common.py Normal file
View File

@@ -0,0 +1,203 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Any, Iterator
from nicegui import ui
NAV_ITEMS: list[tuple[str, str, str]] = [
("overview", "/", "Overview"),
("hedge", "/hedge", "Hedge Analysis"),
("options", "/options", "Options Chain"),
("settings", "/settings", "Settings"),
]
def demo_spot_price() -> float:
return 215.0
def portfolio_snapshot() -> dict[str, float]:
gold_units = 1_000.0
spot = demo_spot_price()
gold_value = gold_units * spot
loan_amount = 145_000.0
margin_call_ltv = 0.75
return {
"gold_value": gold_value,
"loan_amount": loan_amount,
"ltv_ratio": loan_amount / gold_value,
"net_equity": gold_value - loan_amount,
"spot_price": spot,
"margin_call_ltv": margin_call_ltv,
"margin_call_price": loan_amount / (margin_call_ltv * gold_units),
"cash_buffer": 18_500.0,
"hedge_budget": 8_000.0,
}
def strategy_catalog() -> list[dict[str, Any]]:
return [
{
"name": "protective_put",
"label": "Protective Put",
"description": "Full downside protection below the hedge strike with uncapped upside.",
"estimated_cost": 6.25,
"max_drawdown_floor": 210.0,
"coverage": "High",
},
{
"name": "collar",
"label": "Collar",
"description": "Lower premium by financing puts with covered call upside caps.",
"estimated_cost": 2.10,
"max_drawdown_floor": 208.0,
"upside_cap": 228.0,
"coverage": "Balanced",
},
{
"name": "laddered_puts",
"label": "Laddered Puts",
"description": "Multiple maturities and strikes reduce roll concentration and smooth protection.",
"estimated_cost": 4.45,
"max_drawdown_floor": 205.0,
"coverage": "Layered",
},
]
def quick_recommendations() -> list[dict[str, str]]:
portfolio = portfolio_snapshot()
ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100
return [
{
"title": "Balanced hedge favored",
"summary": "A collar keeps the current LTV comfortably below the margin threshold while limiting upfront spend.",
"tone": "positive",
},
{
"title": f"{ltv_gap:.1f} pts LTV headroom",
"summary": "You still have room before a margin trigger, so prefer cost-efficient protection over maximum convexity.",
"tone": "info",
},
{
"title": "Roll window approaching",
"summary": "Stage long-dated puts now and keep a near-dated layer for event risk over the next quarter.",
"tone": "warning",
},
]
def option_chain() -> list[dict[str, Any]]:
spot = demo_spot_price()
expiries = ["2026-04-17", "2026-06-19", "2026-09-18"]
strikes = [190.0, 200.0, 210.0, 215.0, 220.0, 230.0]
rows: list[dict[str, Any]] = []
for expiry in expiries:
for strike in strikes:
distance = (strike - spot) / spot
for option_type in ("put", "call"):
premium_base = 8.2 if option_type == "put" else 7.1
premium = round(max(1.1, premium_base - abs(distance) * 18 + (0.8 if expiry == "2026-09-18" else 0.0)), 2)
delta = round((0.5 - distance * 1.8) * (-1 if option_type == "put" else 1), 3)
rows.append(
{
"symbol": f"GLD {expiry} {option_type.upper()} {strike:.0f}",
"expiry": expiry,
"type": option_type,
"strike": strike,
"premium": premium,
"bid": round(max(premium - 0.18, 0.5), 2),
"ask": round(premium + 0.18, 2),
"open_interest": int(200 + abs(spot - strike) * 14),
"volume": int(75 + abs(spot - strike) * 8),
"delta": max(-0.95, min(0.95, delta)),
"gamma": round(max(0.012, 0.065 - abs(distance) * 0.12), 3),
"theta": round(-0.014 - abs(distance) * 0.025, 3),
"vega": round(0.09 + max(0.0, 0.24 - abs(distance) * 0.6), 3),
"rho": round((0.04 + abs(distance) * 0.09) * (-1 if option_type == "put" else 1), 3),
}
)
return rows
def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]:
strategy = next((item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0])
spot = demo_spot_price()
floor = float(strategy.get("max_drawdown_floor", spot * 0.95))
cap = strategy.get("upside_cap")
cost = float(strategy["estimated_cost"])
scenario_prices = [round(spot * (1 + pct / 100), 2) for pct in range(-25, 30, 5)]
benefits: list[float] = []
for price in scenario_prices:
payoff = max(floor - price, 0.0)
if isinstance(cap, (int, float)) and price > float(cap):
payoff -= price - float(cap)
benefits.append(round(payoff - cost, 2))
scenario_price = round(spot * (1 + scenario_pct / 100), 2)
unhedged_equity = scenario_price * 1_000 - 145_000.0
scenario_payoff = max(floor - scenario_price, 0.0)
capped_upside = 0.0
if isinstance(cap, (int, float)) and scenario_price > float(cap):
capped_upside = -(scenario_price - float(cap))
hedged_equity = unhedged_equity + scenario_payoff + capped_upside - cost * 1_000
waterfall_steps = [
("Base equity", round(70_000.0, 2)),
("Spot move", round((scenario_price - spot) * 1_000, 2)),
("Option payoff", round(scenario_payoff * 1_000, 2)),
("Call cap", round(capped_upside * 1_000, 2)),
("Hedge cost", round(-cost * 1_000, 2)),
("Net equity", round(hedged_equity, 2)),
]
return {
"strategy": strategy,
"scenario_pct": scenario_pct,
"scenario_price": scenario_price,
"scenario_series": [{"price": price, "benefit": benefit} for price, benefit in zip(scenario_prices, benefits, strict=True)],
"waterfall_steps": waterfall_steps,
"unhedged_equity": round(unhedged_equity, 2),
"hedged_equity": round(hedged_equity, 2),
}
@contextmanager
def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]:
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container:
with ui.header(elevated=False).classes("items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90"):
with ui.row().classes("items-center gap-3"):
ui.icon("shield").classes("text-2xl text-sky-500")
with ui.column().classes("gap-0"):
ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50")
ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-2 max-sm:flex-wrap"):
for key, href, label in NAV_ITEMS:
active = key == current
link_classes = (
"rounded-lg px-4 py-2 text-sm font-medium no-underline transition "
+ (
"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
if active
else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
)
)
ui.link(label, href).classes(link_classes)
with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"):
with ui.column().classes("gap-1"):
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(subtitle).classes("text-slate-500 dark:text-slate-400")
yield container
def recommendation_style(tone: str) -> str:
return {
"positive": "border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/30",
"warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30",
"info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30",
}.get(tone, "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900")

126
app/pages/hedge.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from nicegui import ui
from app.pages.common import dashboard_page, demo_spot_price, strategy_catalog, strategy_metrics
def _cost_benefit_options(metrics: dict) -> dict:
return {
"tooltip": {"trigger": "axis"},
"xAxis": {
"type": "category",
"data": [f"${point['price']:.0f}" for point in metrics["scenario_series"]],
"name": "GLD spot",
},
"yAxis": {"type": "value", "name": "Net benefit / oz"},
"series": [
{
"type": "bar",
"data": [point["benefit"] for point in metrics["scenario_series"]],
"itemStyle": {
"color": "#0ea5e9",
},
}
],
}
def _waterfall_options(metrics: dict) -> dict:
steps = metrics["waterfall_steps"]
running = 0.0
base = []
values = []
for index, (_, amount) in enumerate(steps):
if index == 0:
base.append(0)
values.append(amount)
running = amount
elif index == len(steps) - 1:
base.append(0)
values.append(amount)
else:
base.append(running)
values.append(amount)
running += amount
return {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"xAxis": {"type": "category", "data": [label for label, _ in steps]},
"yAxis": {"type": "value", "name": "USD"},
"series": [
{"type": "bar", "stack": "total", "data": base, "itemStyle": {"color": "rgba(0,0,0,0)"}},
{
"type": "bar",
"stack": "total",
"data": values,
"itemStyle": {
"color": "#22c55e",
},
},
],
}
@ui.page("/hedge")
def hedge_page() -> None:
strategies = strategy_catalog()
strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies}
selected = {"strategy": strategies[0]["name"], "scenario_pct": 0}
with dashboard_page(
"Hedge Analysis",
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.",
"hedge",
):
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
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("Strategy Controls").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
selector = ui.select(strategy_map, value=selected["strategy"], label="Strategy selector").classes("w-full")
slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400")
slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full")
ui.label(f"Current spot reference: ${demo_spot_price():,.2f}").classes("text-sm text-slate-500 dark:text-slate-400")
summary = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900")
charts_row = ui.row().classes("w-full gap-6 max-lg:flex-col")
with charts_row:
cost_chart = ui.echart(_cost_benefit_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900")
waterfall_chart = ui.echart(_waterfall_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900")
def render_summary() -> None:
metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"])
strategy = metrics["strategy"]
summary.clear()
with summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=2).classes("w-full gap-4 max-sm:grid-cols-1"):
cards = [
("Scenario spot", f"${metrics['scenario_price']:,.2f}"),
("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"),
("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"),
("Hedged equity", f"${metrics['hedged_equity']:,.0f}"),
]
for label, value in cards:
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(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100")
ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300")
cost_chart.options = _cost_benefit_options(metrics)
cost_chart.update()
waterfall_chart.options = _waterfall_options(metrics)
waterfall_chart.update()
def refresh_from_selector(event) -> None:
selected["strategy"] = event.value
render_summary()
def refresh_from_slider(event) -> None:
selected["scenario_pct"] = int(event.value)
sign = "+" if selected["scenario_pct"] >= 0 else ""
slider_value.set_text(f"Scenario move: {sign}{selected['scenario_pct']}%")
render_summary()
selector.on_value_change(refresh_from_selector)
slider.on_value_change(refresh_from_slider)
render_summary()

126
app/pages/options.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from nicegui import ui
from app.components import GreeksTable
from app.pages.common import dashboard_page, option_chain, strategy_catalog
@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]}
selected_strategy = {"value": strategy_catalog()[0]["label"]}
chosen_contracts: list[dict] = []
with dashboard_page(
"Options Chain",
"Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.",
"options",
):
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
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"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full")
max_strike = ui.number("Max strike", value=strike_range["max"], min=strike_values[0], max=strike_values[-1], 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")
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")
chain_table = ui.html("").classes("w-full")
greeks = GreeksTable([])
def filtered_rows() -> list[dict]:
return [
row
for row in chain
if row["expiry"] == selected_expiry["value"] and strike_range["min"] <= 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
for contract in chosen_contracts[-3:]:
ui.label(
f"{contract['symbol']} · premium ${contract['premium']:.2f} · Δ {contract['delta']:+.3f}"
).classes("text-sm text-slate-600 dark:text-slate-300")
def add_to_strategy(contract: dict) -> 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'>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'>${row['strike']:.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['bid']:.2f} / ${row['ask']:.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'{row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.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='6' 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()} {row['strike']:.0f}",
on_click=lambda _, contract=row: add_to_strategy(contract),
).props("outline color=primary")
greeks.set_options(rows[:6])
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 update_filters() -> None:
selected_expiry["value"] = expiry_select.value
strike_range["min"] = float(min_strike.value)
strike_range["max"] = float(max_strike.value)
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()
expiry_select.on_value_change(lambda _: update_filters())
min_strike.on_value_change(lambda _: update_filters())
max_strike.on_value_change(lambda _: update_filters())
strategy_select.on_value_change(lambda event: (selected_strategy.__setitem__("value", event.value), render_selection()))
render_selection()
render_chain()

66
app/pages/overview.py Normal file
View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from nicegui import ui
from app.components import PortfolioOverview
from app.pages.common import dashboard_page, portfolio_snapshot, quick_recommendations, recommendation_style, strategy_catalog
@ui.page("/")
@ui.page("/overview")
def overview_page() -> None:
portfolio = portfolio_snapshot()
with dashboard_page(
"Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
"overview",
):
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
summary_cards = [
("Spot Price", f"${portfolio['spot_price']:,.2f}", "GLD reference price"),
("Margin Call Price", f"${portfolio['margin_call_price']:,.2f}", "Implied trigger level"),
("Cash Buffer", f"${portfolio['cash_buffer']:,.0f}", "Available liquidity"),
("Hedge Budget", f"${portfolio['hedge_budget']:,.0f}", "Approved premium budget"),
]
for title, value, caption in summary_cards:
with ui.card().classes("rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"):
ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400")
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")
portfolio_view = PortfolioOverview(margin_call_ltv=portfolio["margin_call_ltv"])
portfolio_view.update(portfolio)
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"):
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(f"Threshold {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"
)
ui.linear_progress(value=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], show_value=False).props("color=warning track-color=grey-3 rounded")
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."
).classes("text-sm text-slate-600 dark:text-slate-300")
ui.label(
"Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required."
).classes("text-sm font-medium text-amber-700 dark:text-amber-300")
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("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
for strategy in strategy_catalog():
with ui.row().classes("w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"):
with ui.column().classes("gap-1"):
ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100")
ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes(
"rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
)
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"):
for rec in quick_recommendations():
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["summary"]).classes("text-sm text-slate-600 dark:text-slate-300")

58
app/pages/settings.py Normal file
View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from nicegui import ui
from app.pages.common import dashboard_page
@ui.page("/settings")
def settings_page() -> None:
with dashboard_page(
"Settings",
"Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.",
"settings",
):
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
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("Portfolio Parameters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
gold_value = ui.number("Gold collateral value", value=215000, min=0, step=1000).classes("w-full")
loan_amount = ui.number("Loan amount", value=145000, min=0, step=1000).classes("w-full")
margin_threshold = ui.number("Margin call LTV", value=0.75, min=0.1, max=0.95, step=0.01).classes("w-full")
hedge_budget = ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full")
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("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
primary_source = ui.select(["yfinance", "ibkr", "alpaca"], value="yfinance", label="Primary source").classes("w-full")
fallback_source = ui.select(["fallback", "yfinance", "manual"], value="fallback", label="Fallback source").classes("w-full")
refresh_interval = ui.number("Refresh interval (seconds)", value=5, min=1, step=1).classes("w-full")
ui.switch("Enable Redis cache", value=True)
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
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("Alert Thresholds").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ltv_warning = ui.number("LTV warning level", value=0.70, min=0.1, max=0.95, step=0.01).classes("w-full")
vol_alert = ui.number("Volatility spike alert", value=0.25, min=0.01, max=2.0, step=0.01).classes("w-full")
price_alert = ui.number("Spot drawdown alert (%)", value=7.5, min=0.1, max=50.0, step=0.5).classes("w-full")
email_alerts = ui.switch("Email alerts", value=False)
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("Export / Import").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
export_format = ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full")
ui.switch("Include scenario history", value=True)
ui.switch("Include option selections", value=True)
ui.button("Import settings", icon="upload").props("outline color=primary")
ui.button("Export settings", icon="download").props("outline color=primary")
def save_settings() -> None:
status.set_text(
"Saved configuration: "
f"gold=${gold_value.value:,.0f}, loan=${loan_amount.value:,.0f}, margin={margin_threshold.value:.2f}, "
f"primary={primary_source.value}, fallback={fallback_source.value}, refresh={refresh_interval.value}s, "
f"ltv warning={ltv_warning.value:.2f}, vol={vol_alert.value:.2f}, drawdown={price_alert.value:.1f}%, "
f"email alerts={'on' if email_alerts.value else 'off'}, export={export_format.value}."
)
ui.notify("Settings saved", color="positive")
with ui.row().classes("w-full items-center justify-between gap-4"):
status = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
ui.button("Save settings", on_click=save_settings).props("color=primary")