273 lines
12 KiB
Python
273 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
from nicegui import ui
|
||
|
||
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
|
||
from app.pages.common import dashboard_page
|
||
|
||
|
||
@ui.page("/settings")
|
||
def settings_page() -> None:
|
||
"""Settings page with persistent portfolio configuration."""
|
||
repo = get_portfolio_repository()
|
||
config = repo.load()
|
||
|
||
syncing_entry_basis = False
|
||
|
||
def as_positive_float(value: object) -> float | None:
|
||
try:
|
||
parsed = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return parsed if parsed > 0 else None
|
||
|
||
def as_non_negative_float(value: object) -> float:
|
||
try:
|
||
parsed = float(value)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
return max(parsed, 0.0)
|
||
|
||
with dashboard_page(
|
||
"Settings",
|
||
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
|
||
"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")
|
||
ui.label(
|
||
"Choose whether collateral entry is keyed by start value or by gold weight. The paired field is derived automatically from the entry price."
|
||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||
|
||
entry_basis_mode = ui.select(
|
||
{"value_price": "Start value + entry price", "weight": "Gold weight + entry price"},
|
||
value=config.entry_basis_mode,
|
||
label="Collateral entry basis",
|
||
).classes("w-full")
|
||
|
||
entry_price = ui.number(
|
||
"Entry price ($/oz)",
|
||
value=config.entry_price,
|
||
min=0.01,
|
||
step=0.01,
|
||
).classes("w-full")
|
||
|
||
gold_value = ui.number(
|
||
"Collateral start value ($)",
|
||
value=config.gold_value,
|
||
min=0.01,
|
||
step=1000,
|
||
).classes("w-full")
|
||
|
||
gold_ounces = ui.number(
|
||
"Gold weight (oz)",
|
||
value=config.gold_ounces,
|
||
min=0.0001,
|
||
step=0.01,
|
||
).classes("w-full")
|
||
|
||
loan_amount = ui.number(
|
||
"Loan amount ($)",
|
||
value=config.loan_amount,
|
||
min=0,
|
||
step=1000,
|
||
).classes("w-full")
|
||
|
||
margin_threshold = ui.number(
|
||
"Margin call LTV threshold",
|
||
value=config.margin_threshold,
|
||
min=0.1,
|
||
max=0.95,
|
||
step=0.01,
|
||
).classes("w-full")
|
||
|
||
monthly_budget = ui.number(
|
||
"Monthly hedge budget ($)",
|
||
value=config.monthly_budget,
|
||
min=0,
|
||
step=500,
|
||
).classes("w-full")
|
||
|
||
derived_hint = ui.label().classes("text-sm text-slate-500 dark:text-slate-400")
|
||
|
||
with ui.row().classes("w-full gap-2 mt-4 rounded-lg bg-slate-50 p-4 dark:bg-slate-800"):
|
||
ui.label("Current LTV:").classes("font-medium")
|
||
ltv_display = ui.label()
|
||
|
||
ui.label("Margin buffer:").classes("ml-4 font-medium")
|
||
buffer_display = ui.label()
|
||
|
||
ui.label("Margin call at:").classes("ml-4 font-medium")
|
||
margin_price_display = ui.label()
|
||
|
||
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=config.primary_source,
|
||
label="Primary source",
|
||
).classes("w-full")
|
||
fallback_source = ui.select(
|
||
["fallback", "yfinance", "manual"],
|
||
value=config.fallback_source,
|
||
label="Fallback source",
|
||
).classes("w-full")
|
||
refresh_interval = ui.number(
|
||
"Refresh interval (seconds)",
|
||
value=config.refresh_interval,
|
||
min=1,
|
||
step=1,
|
||
).classes("w-full")
|
||
|
||
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=config.ltv_warning,
|
||
min=0.1,
|
||
max=0.95,
|
||
step=0.01,
|
||
).classes("w-full")
|
||
vol_alert = ui.number(
|
||
"Volatility spike alert",
|
||
value=config.volatility_spike,
|
||
min=0.01,
|
||
max=2.0,
|
||
step=0.01,
|
||
).classes("w-full")
|
||
price_alert = ui.number(
|
||
"Spot drawdown alert (%)",
|
||
value=config.spot_drawdown,
|
||
min=0.1,
|
||
max=50.0,
|
||
step=0.5,
|
||
).classes("w-full")
|
||
email_alerts = ui.switch("Email alerts", value=config.email_alerts)
|
||
|
||
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")
|
||
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 apply_entry_basis_mode() -> None:
|
||
mode = str(entry_basis_mode.value or "value_price")
|
||
if mode == "weight":
|
||
gold_value.props("readonly")
|
||
gold_ounces.props(remove="readonly")
|
||
derived_hint.set_text("Gold weight is the editable basis; start value is derived from weight × entry price.")
|
||
else:
|
||
gold_ounces.props("readonly")
|
||
gold_value.props(remove="readonly")
|
||
derived_hint.set_text("Start value is the editable basis; gold weight is derived from start value ÷ entry price.")
|
||
|
||
def update_entry_basis(*_args: object) -> None:
|
||
nonlocal syncing_entry_basis
|
||
apply_entry_basis_mode()
|
||
if syncing_entry_basis:
|
||
return
|
||
|
||
price = as_positive_float(entry_price.value)
|
||
if price is None:
|
||
update_calculations()
|
||
return
|
||
|
||
syncing_entry_basis = True
|
||
try:
|
||
mode = str(entry_basis_mode.value or "value_price")
|
||
if mode == "weight":
|
||
ounces = as_positive_float(gold_ounces.value)
|
||
if ounces is not None:
|
||
gold_value.value = round(ounces * price, 2)
|
||
else:
|
||
start_value = as_positive_float(gold_value.value)
|
||
if start_value is not None:
|
||
gold_ounces.value = round(start_value / price, 6)
|
||
finally:
|
||
syncing_entry_basis = False
|
||
|
||
update_calculations()
|
||
|
||
def update_calculations(*_args: object) -> None:
|
||
price = as_positive_float(entry_price.value)
|
||
collateral_value = as_positive_float(gold_value.value)
|
||
ounces = as_positive_float(gold_ounces.value)
|
||
loan = as_non_negative_float(loan_amount.value)
|
||
margin = as_positive_float(margin_threshold.value)
|
||
|
||
if collateral_value is not None and collateral_value > 0:
|
||
ltv = (loan / collateral_value) * 100
|
||
buffer = ((margin or 0.0) - loan / collateral_value) * 100 if margin is not None else 0.0
|
||
ltv_display.set_text(f"{ltv:.1f}%")
|
||
buffer_display.set_text(f"{buffer:.1f}%")
|
||
else:
|
||
ltv_display.set_text("—")
|
||
buffer_display.set_text("—")
|
||
|
||
if margin is not None and ounces is not None and ounces > 0:
|
||
margin_price_display.set_text(f"${loan / (margin * ounces):,.2f}/oz")
|
||
elif margin is not None and price is not None and collateral_value is not None and collateral_value > 0:
|
||
implied_ounces = collateral_value / price
|
||
margin_price_display.set_text(f"${loan / (margin * implied_ounces):,.2f}/oz")
|
||
else:
|
||
margin_price_display.set_text("—")
|
||
|
||
for element in (entry_basis_mode, entry_price, gold_value, gold_ounces):
|
||
element.on_value_change(update_entry_basis)
|
||
for element in (loan_amount, margin_threshold):
|
||
element.on_value_change(update_calculations)
|
||
|
||
apply_entry_basis_mode()
|
||
update_entry_basis()
|
||
|
||
def save_settings() -> None:
|
||
try:
|
||
new_config = PortfolioConfig(
|
||
gold_value=as_positive_float(gold_value.value),
|
||
entry_price=as_positive_float(entry_price.value),
|
||
gold_ounces=as_positive_float(gold_ounces.value),
|
||
entry_basis_mode=str(entry_basis_mode.value),
|
||
loan_amount=as_non_negative_float(loan_amount.value),
|
||
margin_threshold=float(margin_threshold.value),
|
||
monthly_budget=float(monthly_budget.value),
|
||
ltv_warning=float(ltv_warning.value),
|
||
primary_source=str(primary_source.value),
|
||
fallback_source=str(fallback_source.value),
|
||
refresh_interval=int(refresh_interval.value),
|
||
volatility_spike=float(vol_alert.value),
|
||
spot_drawdown=float(price_alert.value),
|
||
email_alerts=bool(email_alerts.value),
|
||
)
|
||
|
||
repo.save(new_config)
|
||
|
||
status.set_text(
|
||
f"Saved: basis={new_config.entry_basis_mode}, start=${new_config.gold_value:,.0f}, "
|
||
f"entry=${new_config.entry_price:,.2f}/oz, weight={new_config.gold_ounces:,.2f} oz, "
|
||
f"LTV={new_config.current_ltv:.1%}, trigger=${new_config.margin_call_price:,.2f}/oz"
|
||
)
|
||
ui.notify("Settings saved successfully", color="positive")
|
||
except ValueError as e:
|
||
ui.notify(f"Validation error: {e}", color="negative")
|
||
except Exception as e:
|
||
ui.notify(f"Failed to save: {e}", color="negative")
|
||
|
||
with ui.row().classes("mt-6 w-full items-center justify-between gap-4"):
|
||
status = ui.label(
|
||
f"Current: start=${config.gold_value:,.0f}, entry=${config.entry_price:,.2f}/oz, "
|
||
f"weight={config.gold_ounces:,.2f} oz, current LTV={config.current_ltv:.1%}"
|
||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||
ui.button("Save settings", on_click=save_settings).props("color=primary")
|