feat(PORTFOLIO-002): add position storage costs

This commit is contained in:
Bu5hm4nn
2026-03-28 23:48:41 +01:00
parent e148d55cda
commit 0e972e9dd6
5 changed files with 501 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import Request
from fastapi.responses import RedirectResponse
@@ -21,6 +22,7 @@ from app.pages.common import (
from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.ltv_history import LtvHistoryChartModel, LtvHistoryService
from app.services.runtime import get_data_service
from app.services.storage_costs import calculate_total_storage_cost
from app.services.turnstile import load_turnstile_settings
logger = logging.getLogger(__name__)
@@ -163,6 +165,17 @@ async def overview_page(workspace_id: str) -> None:
configured_gold_value = float(config.gold_value or 0.0)
portfolio["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER
portfolio["hedge_budget"] = float(config.monthly_budget)
# Calculate storage costs for positions
positions = config.positions
current_values: dict[str, Decimal] = {}
for pos in positions:
# Use entry value as proxy for current value (would need live prices for accurate calc)
current_values[str(pos.id)] = pos.entry_value
total_annual_storage_cost = calculate_total_storage_cost(positions, current_values)
portfolio["annual_storage_cost"] = float(total_annual_storage_cost)
portfolio["storage_cost_pct"] = (float(total_annual_storage_cost) / float(portfolio["gold_value"]) * 100) if portfolio["gold_value"] > 0 else 0.0
alert_status = AlertService().evaluate(config, portfolio)
ltv_history_service = LtvHistoryService(repository=LtvHistoryRepository(base_path=repo.base_path))
ltv_history_notice: str | None = None
@@ -362,6 +375,11 @@ async def overview_page(workspace_id: str) -> None:
f"${portfolio['hedge_budget']:,.0f}",
"Monthly budget from saved settings",
),
(
"Storage Costs",
f"${portfolio['annual_storage_cost']:,.2f}/yr ({portfolio['storage_cost_pct']:.2f}%)",
"Annual vault storage for physical positions (GLD expense ratio baked into share price)",
),
]
for title, value, caption in summary_cards:
with ui.card().classes(

View File

@@ -15,6 +15,7 @@ from app.models.workspace import get_workspace_repository
from app.pages.common import dashboard_page, split_page_panes
from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.settings_status import save_status_text
from app.services.storage_costs import get_default_storage_cost_for_underlying
logger = logging.getLogger(__name__)
@@ -334,6 +335,19 @@ def settings_page(workspace_id: str) -> None:
label="Underlying",
).classes("w-full")
def update_storage_cost_default() -> None:
"""Update storage cost defaults based on underlying selection."""
underlying = str(pos_underlying.value)
default_basis, default_period = get_default_storage_cost_for_underlying(underlying)
if default_basis is not None:
pos_storage_cost_basis.value = float(default_basis)
pos_storage_cost_period.value = default_period or "annual"
else:
pos_storage_cost_basis.value = 0.0
pos_storage_cost_period.value = "annual"
pos_underlying.on_value_change(lambda _: update_storage_cost_default())
pos_quantity = ui.number(
"Quantity",
value=100.0,
@@ -369,6 +383,23 @@ def settings_page(workspace_id: str) -> None:
placeholder="Add notes about this position...",
).classes("w-full")
ui.separator().classes("my-3")
ui.label("Storage Costs (optional)").classes("text-sm font-semibold text-slate-700 dark:text-slate-300")
ui.label("For physical gold (XAU), defaults to 0.12% annual vault storage.").classes("text-xs text-slate-500 dark:text-slate-400 mb-2")
pos_storage_cost_basis = ui.number(
"Storage cost (% per year or fixed $)",
value=0.0,
min=0.0,
step=0.01,
).classes("w-full")
pos_storage_cost_period = ui.select(
{"annual": "Annual", "monthly": "Monthly"},
value="annual",
label="Cost period",
).classes("w-full")
with ui.row().classes("w-full gap-3 mt-4"):
ui.button("Cancel", on_click=lambda: add_position_dialog.close()).props("outline")
ui.button("Add Position", on_click=lambda: add_position_from_form()).props("color=primary")
@@ -376,15 +407,22 @@ def settings_page(workspace_id: str) -> None:
def add_position_from_form() -> None:
"""Add a new position from the form."""
try:
underlying = str(pos_underlying.value)
storage_cost_basis_val = float(pos_storage_cost_basis.value)
storage_cost_basis = Decimal(str(storage_cost_basis_val)) if storage_cost_basis_val > 0 else None
storage_cost_period = str(pos_storage_cost_period.value) if storage_cost_basis else None
new_position = Position(
id=uuid4(),
underlying=str(pos_underlying.value),
underlying=underlying,
quantity=Decimal(str(pos_quantity.value)),
unit=str(pos_unit.value),
entry_price=Decimal(str(pos_entry_price.value)),
entry_date=date.fromisoformat(str(pos_entry_date.value)),
entry_basis_mode="weight",
notes=str(pos_notes.value or ""),
storage_cost_basis=storage_cost_basis,
storage_cost_period=storage_cost_period,
)
workspace_repo.add_position(workspace_id, new_position)
add_position_dialog.close()
@@ -423,6 +461,19 @@ def settings_page(workspace_id: str) -> None:
ui.label(f"Value: ${float(pos.entry_value):,.2f}").classes(
"text-xs font-semibold text-emerald-600 dark:text-emerald-400"
)
# Show storage cost if configured
if pos.storage_cost_basis is not None:
basis_val = float(pos.storage_cost_basis)
period = pos.storage_cost_period or "annual"
if basis_val < 1:
# Percentage
storage_label = f"{basis_val:.2f}% {period} storage"
else:
# Fixed amount
storage_label = f"${basis_val:,.2f} {period} storage"
ui.label(f"Storage: {storage_label}").classes(
"text-xs text-slate-500 dark:text-slate-400"
)
with ui.row().classes("gap-1"):
ui.button(
icon="delete",