feat(PORT-001A): add collateral entry basis settings
This commit is contained in:
@@ -66,14 +66,20 @@ class PortfolioConfig:
|
||||
"""User portfolio configuration with validation.
|
||||
|
||||
Attributes:
|
||||
gold_value: Current gold collateral value in USD
|
||||
gold_value: Collateral baseline value in USD at entry
|
||||
entry_price: Gold entry price per ounce in USD
|
||||
gold_ounces: Canonical gold collateral weight in ounces
|
||||
entry_basis_mode: Preferred settings UI input mode
|
||||
loan_amount: Outstanding loan amount in USD
|
||||
margin_threshold: LTV threshold for margin call (default 0.75)
|
||||
monthly_budget: Approved monthly hedge budget
|
||||
ltv_warning: LTV warning level for alerts (default 0.70)
|
||||
"""
|
||||
|
||||
gold_value: float = 215000.0
|
||||
gold_value: float | None = None
|
||||
entry_price: float | None = 215.0
|
||||
gold_ounces: float | None = None
|
||||
entry_basis_mode: str = "value_price"
|
||||
loan_amount: float = 145000.0
|
||||
margin_threshold: float = 0.75
|
||||
monthly_budget: float = 8000.0
|
||||
@@ -89,14 +95,57 @@ class PortfolioConfig:
|
||||
spot_drawdown: float = 7.5
|
||||
email_alerts: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate configuration after initialization."""
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalize entry basis fields and validate configuration."""
|
||||
self._normalize_entry_basis()
|
||||
self.validate()
|
||||
|
||||
def _normalize_entry_basis(self) -> None:
|
||||
"""Resolve user input into canonical weight + entry price representation."""
|
||||
if self.entry_basis_mode not in {"value_price", "weight"}:
|
||||
raise ValueError("Entry basis mode must be 'value_price' or 'weight'")
|
||||
|
||||
if self.entry_price is None or self.entry_price <= 0:
|
||||
raise ValueError("Entry price must be positive")
|
||||
|
||||
if self.gold_value is not None and self.gold_value <= 0:
|
||||
raise ValueError("Gold value must be positive")
|
||||
if self.gold_ounces is not None and self.gold_ounces <= 0:
|
||||
raise ValueError("Gold weight must be positive")
|
||||
|
||||
if self.gold_value is None and self.gold_ounces is None:
|
||||
self.gold_value = 215000.0
|
||||
self.gold_ounces = self.gold_value / self.entry_price
|
||||
return
|
||||
|
||||
if self.gold_value is None and self.gold_ounces is not None:
|
||||
self.gold_value = self.gold_ounces * self.entry_price
|
||||
return
|
||||
|
||||
if self.gold_ounces is None and self.gold_value is not None:
|
||||
self.gold_ounces = self.gold_value / self.entry_price
|
||||
return
|
||||
|
||||
assert self.gold_value is not None
|
||||
assert self.gold_ounces is not None
|
||||
derived_gold_value = self.gold_ounces * self.entry_price
|
||||
tolerance = max(0.01, abs(derived_gold_value) * 1e-9)
|
||||
if abs(self.gold_value - derived_gold_value) > tolerance:
|
||||
raise ValueError("Gold value and weight contradict each other")
|
||||
self.gold_value = derived_gold_value
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration values."""
|
||||
assert self.gold_value is not None
|
||||
assert self.entry_price is not None
|
||||
assert self.gold_ounces is not None
|
||||
|
||||
if self.gold_value <= 0:
|
||||
raise ValueError("Gold value must be positive")
|
||||
if self.entry_price <= 0:
|
||||
raise ValueError("Entry price must be positive")
|
||||
if self.gold_ounces <= 0:
|
||||
raise ValueError("Gold weight must be positive")
|
||||
if self.loan_amount < 0:
|
||||
raise ValueError("Loan amount cannot be negative")
|
||||
if self.loan_amount >= self.gold_value:
|
||||
@@ -111,6 +160,7 @@ class PortfolioConfig:
|
||||
@property
|
||||
def current_ltv(self) -> float:
|
||||
"""Calculate current loan-to-value ratio."""
|
||||
assert self.gold_value is not None
|
||||
if self.gold_value == 0:
|
||||
return 0.0
|
||||
return self.loan_amount / self.gold_value
|
||||
@@ -123,19 +173,27 @@ class PortfolioConfig:
|
||||
@property
|
||||
def net_equity(self) -> float:
|
||||
"""Calculate net equity (gold value - loan)."""
|
||||
assert self.gold_value is not None
|
||||
return self.gold_value - self.loan_amount
|
||||
|
||||
@property
|
||||
def margin_call_price(self) -> float:
|
||||
"""Calculate gold price at which margin call occurs."""
|
||||
if self.margin_threshold == 0:
|
||||
"""Calculate gold price per ounce at which margin call occurs."""
|
||||
assert self.gold_ounces is not None
|
||||
if self.margin_threshold == 0 or self.gold_ounces == 0:
|
||||
return float("inf")
|
||||
return self.loan_amount / self.margin_threshold
|
||||
return self.loan_amount / (self.margin_threshold * self.gold_ounces)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert configuration to dictionary."""
|
||||
assert self.gold_value is not None
|
||||
assert self.entry_price is not None
|
||||
assert self.gold_ounces is not None
|
||||
return {
|
||||
"gold_value": self.gold_value,
|
||||
"entry_price": self.entry_price,
|
||||
"gold_ounces": self.gold_ounces,
|
||||
"entry_basis_mode": self.entry_basis_mode,
|
||||
"loan_amount": self.loan_amount,
|
||||
"margin_threshold": self.margin_threshold,
|
||||
"monthly_budget": self.monthly_budget,
|
||||
@@ -162,8 +220,7 @@ class PortfolioRepository:
|
||||
|
||||
CONFIG_PATH = Path("data/portfolio_config.json")
|
||||
|
||||
def __init__(self):
|
||||
# Ensure data directory exists
|
||||
def __init__(self) -> None:
|
||||
self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save(self, config: PortfolioConfig) -> None:
|
||||
@@ -190,7 +247,6 @@ class PortfolioRepository:
|
||||
return PortfolioConfig()
|
||||
|
||||
|
||||
# Singleton repository instance
|
||||
_portfolio_repo: PortfolioRepository | None = None
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from app.models.portfolio import PortfolioConfig, get_portfolio_repository
|
||||
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
|
||||
from app.services.runtime import get_data_service
|
||||
|
||||
_REFERENCE_SPOT_PRICE = 215.0
|
||||
_DEFAULT_CASH_BUFFER = 18_500.0
|
||||
|
||||
|
||||
@@ -24,23 +23,24 @@ def _format_timestamp(value: str | None) -> str:
|
||||
|
||||
|
||||
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
|
||||
fallback_spot_price = float(config.entry_price or 0.0)
|
||||
spot_price = float(quote.get("price", fallback_spot_price))
|
||||
configured_gold_value = float(config.gold_value or 0.0)
|
||||
gold_units = float(config.gold_ounces or 0.0)
|
||||
live_gold_value = gold_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_units": gold_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,
|
||||
"margin_call_price": loan_amount / (margin_call_ltv * gold_units) if gold_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")),
|
||||
|
||||
@@ -2,20 +2,35 @@ from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from app.pages.common import dashboard_page
|
||||
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
|
||||
from app.pages.common import dashboard_page
|
||||
|
||||
|
||||
@ui.page("/settings")
|
||||
def settings_page():
|
||||
def settings_page() -> None:
|
||||
"""Settings page with persistent portfolio configuration."""
|
||||
# Load current 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, preferred market data inputs, alert thresholds, and import/export behavior.",
|
||||
"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"):
|
||||
@@ -23,46 +38,70 @@ def settings_page():
|
||||
"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=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
|
||||
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")
|
||||
|
||||
# Show calculated values
|
||||
with ui.row().classes("w-full gap-2 mt-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg"):
|
||||
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(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}")
|
||||
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"
|
||||
@@ -79,33 +118,11 @@ def settings_page():
|
||||
label="Fallback source",
|
||||
).classes("w-full")
|
||||
refresh_interval = ui.number(
|
||||
"Refresh interval (seconds)",
|
||||
value=config.refresh_interval,
|
||||
min=1,
|
||||
step=1
|
||||
"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(
|
||||
@@ -113,52 +130,116 @@ def settings_page():
|
||||
):
|
||||
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
|
||||
"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
|
||||
"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
|
||||
"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
|
||||
)
|
||||
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")
|
||||
export_format = ui.select(
|
||||
["json", "csv", "yaml"],
|
||||
value="json",
|
||||
label="Export format"
|
||||
).classes("w-full")
|
||||
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():
|
||||
"""Save settings with validation and persistence."""
|
||||
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:
|
||||
# Create new config from form values
|
||||
new_config = PortfolioConfig(
|
||||
gold_value=float(gold_value.value),
|
||||
loan_amount=float(loan_amount.value),
|
||||
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),
|
||||
@@ -169,27 +250,23 @@ def settings_page():
|
||||
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%}"
|
||||
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("w-full items-center justify-between gap-4 mt-6"):
|
||||
with ui.row().classes("mt-6 w-full items-center justify-between gap-4"):
|
||||
status = ui.label(
|
||||
f"Current: gold=${config.gold_value:,.0f}, loan=${config.loan_amount:,.0f}, "
|
||||
f"current LTV={config.current_ltv:.1%}"
|
||||
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")
|
||||
|
||||
@@ -272,13 +272,30 @@ DATA-001 (Price Feed)
|
||||
|
||||
**Dependencies:** stable deployed app on VPS
|
||||
|
||||
### PORT-001A: Collateral Entry Basis in Settings [P1, M] **[depends: PORT-001]**
|
||||
**As a** portfolio manager, **I want** to store my collateral entry basis **so that** the dashboard can derive position size consistently from either cost basis or weight.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Settings supports entering either:
|
||||
- total collateral start value **and** entry price, or
|
||||
- gold weight directly
|
||||
- Updating one mode calculates the other representation automatically
|
||||
- Persist both the chosen input mode and the derived values needed by the app
|
||||
- Validation prevents impossible or contradictory values (zero/negative price, value, or weight)
|
||||
- UI makes the relationship explicit: `weight = start_value / entry_price`
|
||||
- Browser-visible test covers switching modes and saving derived values
|
||||
|
||||
**Technical Notes:**
|
||||
- Extend `PortfolioConfig` / settings persistence with entry-basis fields
|
||||
- Keep overview and other consumers compatible with existing portfolio settings
|
||||
- Prefer a single canonical stored representation plus derived display fields to avoid drift
|
||||
|
||||
**Dependencies:** PORT-001
|
||||
|
||||
## Implementation Priority Queue
|
||||
|
||||
1. **DATA-002A** - Fix options UX/performance regression
|
||||
2. **DATA-001A** - Remove misleading mock overview price
|
||||
3. **OPS-001** - Add Caddy route once app behavior is stable
|
||||
4. **DATA-003** - Risk metrics
|
||||
5. **PORT-002** - Risk management safety
|
||||
6. **EXEC-001** - Core user workflow
|
||||
7. **EXEC-002** - Execution capability
|
||||
8. Remaining features
|
||||
1. **PORT-001A** - Add collateral entry basis and derived weight/value handling in settings
|
||||
2. **PORT-002** - Risk management safety
|
||||
3. **EXEC-001** - Core user workflow
|
||||
4. **EXEC-002** - Execution capability
|
||||
5. Remaining features
|
||||
|
||||
@@ -29,4 +29,13 @@ def test_homepage_and_options_page_render() -> None:
|
||||
assert "RuntimeError" not in body_text
|
||||
page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True)
|
||||
|
||||
page.goto(f"{BASE_URL}/settings", wait_until="domcontentloaded", timeout=30000)
|
||||
expect(page.locator("text=Settings").first).to_be_visible(timeout=15000)
|
||||
expect(page.locator("text=Collateral entry basis").first).to_be_visible(timeout=15000)
|
||||
expect(page.locator("text=Entry price ($/oz)").first).to_be_visible(timeout=15000)
|
||||
settings_text = page.locator("body").inner_text(timeout=15000)
|
||||
assert "RuntimeError" not in settings_text
|
||||
assert "Server error" not in settings_text
|
||||
page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True)
|
||||
|
||||
browser.close()
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.portfolio import PortfolioConfig
|
||||
|
||||
|
||||
def test_ltv_calculation(sample_portfolio) -> None:
|
||||
assert sample_portfolio.current_ltv == pytest.approx(0.60, rel=1e-12)
|
||||
@@ -20,3 +22,57 @@ def test_net_equity_calculation(sample_portfolio) -> None:
|
||||
def test_margin_call_threshold(sample_portfolio) -> None:
|
||||
assert sample_portfolio.margin_call_price() == pytest.approx(368.0, rel=1e-12)
|
||||
assert sample_portfolio.ltv_at_price(sample_portfolio.margin_call_price()) == pytest.approx(0.75, rel=1e-12)
|
||||
|
||||
|
||||
def test_portfolio_config_derives_gold_weight_from_start_value_and_entry_price() -> None:
|
||||
config = PortfolioConfig(
|
||||
gold_value=215_000.0,
|
||||
entry_price=215.0,
|
||||
entry_basis_mode="value_price",
|
||||
)
|
||||
|
||||
assert config.gold_ounces == pytest.approx(1_000.0, rel=1e-12)
|
||||
assert config.gold_value == pytest.approx(215_000.0, rel=1e-12)
|
||||
assert config.entry_basis_mode == "value_price"
|
||||
|
||||
|
||||
def test_portfolio_config_derives_start_value_from_gold_weight_and_entry_price() -> None:
|
||||
config = PortfolioConfig(
|
||||
gold_ounces=1_000.0,
|
||||
entry_price=215.0,
|
||||
entry_basis_mode="weight",
|
||||
)
|
||||
|
||||
assert config.gold_value == pytest.approx(215_000.0, rel=1e-12)
|
||||
assert config.gold_ounces == pytest.approx(1_000.0, rel=1e-12)
|
||||
assert config.entry_basis_mode == "weight"
|
||||
|
||||
|
||||
def test_portfolio_config_serializes_canonical_entry_basis_fields() -> None:
|
||||
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0)
|
||||
|
||||
data = config.to_dict()
|
||||
|
||||
assert data["gold_value"] == pytest.approx(215_000.0, rel=1e-12)
|
||||
assert data["gold_ounces"] == pytest.approx(1_000.0, rel=1e-12)
|
||||
assert data["entry_price"] == pytest.approx(215.0, rel=1e-12)
|
||||
assert PortfolioConfig.from_dict(data).gold_ounces == pytest.approx(1_000.0, rel=1e-12)
|
||||
|
||||
|
||||
def test_portfolio_config_keeps_legacy_gold_value_payloads_compatible() -> None:
|
||||
config = PortfolioConfig.from_dict({"gold_value": 215_000.0, "loan_amount": 145_000.0})
|
||||
|
||||
assert config.gold_value == pytest.approx(215_000.0, rel=1e-12)
|
||||
assert config.entry_price == pytest.approx(215.0, rel=1e-12)
|
||||
assert config.gold_ounces == pytest.approx(1_000.0, rel=1e-12)
|
||||
|
||||
|
||||
def test_portfolio_config_rejects_invalid_entry_basis_values() -> None:
|
||||
with pytest.raises(ValueError, match="Entry price must be positive"):
|
||||
PortfolioConfig(entry_price=0.0)
|
||||
|
||||
with pytest.raises(ValueError, match="Gold weight must be positive"):
|
||||
PortfolioConfig(gold_ounces=-1.0, entry_price=215.0, entry_basis_mode="weight")
|
||||
|
||||
with pytest.raises(ValueError, match="Gold value and weight contradict each other"):
|
||||
PortfolioConfig(gold_value=215_000.0, gold_ounces=900.0, entry_price=215.0)
|
||||
|
||||
Reference in New Issue
Block a user