feat(PORT-001): Add persistent portfolio configuration with validation

- Create PortfolioConfig dataclass with validation
- Add PortfolioRepository for file-based persistence
- Update settings page with live LTV calculations
- Add real-time calculated displays (LTV, margin buffer, margin call price)
This commit is contained in:
Bu5hm4nn
2026-03-23 22:27:09 +01:00
parent 92c6f62bb8
commit 80a8ffae0c
2 changed files with 281 additions and 89 deletions

View File

@@ -3,10 +3,16 @@ from __future__ import annotations
from nicegui import ui
from app.pages.common import dashboard_page
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
@ui.page("/settings")
def settings_page() -> None:
def settings_page():
"""Settings page with persistent portfolio configuration."""
# Load current configuration
repo = get_portfolio_repository()
config = repo.load()
with dashboard_page(
"Settings",
"Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.",
@@ -17,12 +23,46 @@ def settings_page() -> None:
"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"
)
ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full")
gold_value = ui.number(
"Gold collateral value ($)",
value=config.gold_value,
min=0.01, # Must be positive
step=1000
).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")
# Show calculated values
with ui.row().classes("w-full gap-2 mt-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg"):
ui.label("Current LTV:").classes("font-medium")
ltv_display = ui.label(f"{(config.loan_amount / config.gold_value * 100):.1f}%")
ui.label("Margin buffer:").classes("font-medium ml-4")
buffer_display = ui.label(f"{((config.margin_threshold - config.loan_amount / config.gold_value) * 100):.1f}%")
ui.label("Margin call at:").classes("font-medium ml-4")
margin_price_display = ui.label(f"${(config.loan_amount / config.margin_threshold):,.2f}")
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
@@ -30,53 +70,126 @@ def settings_page() -> None:
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",
value=config.primary_source,
label="Primary source",
).classes("w-full")
fallback_source = ui.select(
["fallback", "yfinance", "manual"],
value="fallback",
value=config.fallback_source,
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)
refresh_interval = ui.number(
"Refresh interval (seconds)",
value=config.refresh_interval,
min=1,
step=1
).classes("w-full")
def update_calculations():
"""Update calculated displays when values change."""
try:
gold = gold_value.value or 1 # Avoid division by zero
loan = loan_amount.value or 0
margin = margin_threshold.value or 0.75
ltv = (loan / gold) * 100
buffer = (margin - loan / gold) * 100
margin_price = loan / margin if margin > 0 else 0
ltv_display.set_text(f"{ltv:.1f}%")
buffer_display.set_text(f"{buffer:.1f}%")
margin_price_display.set_text(f"${margin_price:,.2f}")
except Exception:
pass # Ignore calculation errors during editing
# Connect update function to value changes
gold_value.on_value_change(update_calculations)
loan_amount.on_value_change(update_calculations)
margin_threshold.on_value_change(update_calculations)
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"
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
)
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"
)
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")
def save_settings():
"""Save settings with validation and persistence."""
try:
# Create new config from form values
new_config = PortfolioConfig(
gold_value=float(gold_value.value),
loan_amount=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),
)
# Save to repository
repo.save(new_config)
status.set_text(
f"Saved: gold=${new_config.gold_value:,.0f}, "
f"loan=${new_config.loan_amount:,.0f}, "
f"LTV={new_config.current_ltv:.1%}, "
f"margin={new_config.margin_threshold:.1%}, "
f"buffer={new_config.margin_buffer:.1%}"
)
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("w-full items-center justify-between gap-4"):
status = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
with ui.row().classes("w-full items-center justify-between gap-4 mt-6"):
status = ui.label(
f"Current: gold=${config.gold_value:,.0f}, loan=${config.loan_amount:,.0f}, "
f"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")