From aae67dfd9b13e7c53c10928370584627dcd279a0 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Wed, 25 Mar 2026 19:48:58 +0100 Subject: [PATCH] fix(workspaces): seed new defaults from live quote --- app/domain/portfolio_math.py | 23 +++++++++++++++++------ app/main.py | 17 +++++++++++++++-- app/models/portfolio.py | 16 ++++++++++++++-- app/models/workspace.py | 19 ++++++++++++------- tests/test_workspace.py | 19 +++++++++++++++++++ 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/domain/portfolio_math.py b/app/domain/portfolio_math.py index c830b1f..2b4e954 100644 --- a/app/domain/portfolio_math.py +++ b/app/domain/portfolio_math.py @@ -159,13 +159,11 @@ def strategy_benefit_per_unit(strategy: Mapping[str, Any], *, current_spot: floa return round(float(benefit), 2) -def resolve_portfolio_spot_from_quote( - config: PortfolioConfig, +def resolve_collateral_spot_from_quote( quote: Mapping[str, object], *, fallback_symbol: str | None = None, -) -> tuple[float, str, str]: - configured_price = float(config.entry_price or 0.0) +) -> tuple[float, str, str] | None: quote_price = _safe_quote_price(quote.get("price")) quote_source = str(quote.get("source", "unknown")) quote_updated_at = str(quote.get("updated_at", "")) @@ -173,12 +171,12 @@ def resolve_portfolio_spot_from_quote( quote_unit = str(quote.get("quote_unit", "")).strip().lower() if quote_price <= 0 or not quote_symbol or quote_unit != "share": - return configured_price, "configured_entry_price", "" + return None try: metadata = instrument_metadata(quote_symbol) except ValueError: - return configured_price, "configured_entry_price", "" + return None converted_spot = metadata.price_per_weight_from_asset_price( PricePerAsset(amount=decimal_from_float(quote_price), currency=BaseCurrency.USD, symbol=quote_symbol), @@ -187,6 +185,19 @@ def resolve_portfolio_spot_from_quote( return _decimal_to_float(converted_spot.amount), quote_source, quote_updated_at +def resolve_portfolio_spot_from_quote( + config: PortfolioConfig, + quote: Mapping[str, object], + *, + fallback_symbol: str | None = None, +) -> tuple[float, str, str]: + resolved = resolve_collateral_spot_from_quote(quote, fallback_symbol=fallback_symbol) + if resolved is not None: + return resolved + configured_price = float(config.entry_price or 0.0) + return configured_price, "configured_entry_price", "" + + def portfolio_snapshot_from_config( config: PortfolioConfig | None = None, *, diff --git a/app/main.py b/app/main.py index 2f9a655..abaeb16 100644 --- a/app/main.py +++ b/app/main.py @@ -17,11 +17,13 @@ from nicegui import ui # type: ignore[attr-defined] import app.pages # noqa: F401 from app.api.routes import router as api_router +from app.domain.portfolio_math import resolve_collateral_spot_from_quote +from app.models.portfolio import build_default_portfolio_config from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.services import turnstile as turnstile_service from app.services.cache import CacheService from app.services.data_service import DataService -from app.services.runtime import set_data_service +from app.services.runtime import get_data_service, set_data_service logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) logger = logging.getLogger(__name__) @@ -169,7 +171,18 @@ async def bootstrap_workspace( ): return RedirectResponse(url="/?captcha_error=1", status_code=303) - workspace_id = get_workspace_repository().create_workspace_id() + repo = get_workspace_repository() + config = build_default_portfolio_config() + try: + data_service = get_data_service() + quote = await data_service.get_quote(data_service.default_symbol) + resolved_spot = resolve_collateral_spot_from_quote(quote, fallback_symbol=data_service.default_symbol) + if resolved_spot is not None: + config = build_default_portfolio_config(entry_price=resolved_spot[0]) + except Exception as exc: + logger.warning("Falling back to static default workspace seed: %s", exc) + + workspace_id = repo.create_workspace_id(config=config) response = RedirectResponse(url=f"/{workspace_id}", status_code=303) response.set_cookie( key=WORKSPACE_COOKIE, diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 27651a7..a19707a 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -16,6 +16,17 @@ _DEFAULT_GOLD_OUNCES = 100.0 _LEGACY_DEFAULT_GOLD_OUNCES = 1_000.0 +def build_default_portfolio_config(*, entry_price: float | None = None) -> "PortfolioConfig": + resolved_entry_price = float(entry_price) if entry_price is not None else _DEFAULT_ENTRY_PRICE + gold_value = resolved_entry_price * _DEFAULT_GOLD_OUNCES + return PortfolioConfig( + gold_value=gold_value, + entry_price=resolved_entry_price, + gold_ounces=_DEFAULT_GOLD_OUNCES, + entry_basis_mode="value_price", + ) + + @dataclass(frozen=True) class LombardPortfolio: """Lombard loan portfolio backed by physical gold.""" @@ -122,8 +133,9 @@ class PortfolioConfig: raise ValueError("Gold weight must be positive") if self.gold_value is None and self.gold_ounces is None: - self.gold_value = _DEFAULT_GOLD_VALUE - self.gold_ounces = self.gold_value / self.entry_price + default = build_default_portfolio_config(entry_price=self.entry_price) + self.gold_value = default.gold_value + self.gold_ounces = default.gold_ounces return if self.gold_value is None and self.gold_ounces is not None: diff --git a/app/models/workspace.py b/app/models/workspace.py index f9b5756..0515202 100644 --- a/app/models/workspace.py +++ b/app/models/workspace.py @@ -4,7 +4,7 @@ import re from pathlib import Path from uuid import uuid4 -from app.models.portfolio import PortfolioConfig, PortfolioRepository +from app.models.portfolio import PortfolioConfig, PortfolioRepository, build_default_portfolio_config WORKSPACE_COOKIE = "workspace_id" _WORKSPACE_ID_RE = re.compile( @@ -35,17 +35,22 @@ class WorkspaceRepository: return False return True - def create_workspace(self, workspace_id: str | None = None) -> PortfolioConfig: + def create_workspace( + self, + workspace_id: str | None = None, + *, + config: PortfolioConfig | None = None, + ) -> PortfolioConfig: resolved_workspace_id = workspace_id or str(uuid4()) if not self.is_valid_workspace_id(resolved_workspace_id): raise ValueError("workspace_id must be a UUID4 string") - config = PortfolioConfig() - self.save_portfolio_config(resolved_workspace_id, config) - return config + created_config = config or build_default_portfolio_config() + self.save_portfolio_config(resolved_workspace_id, created_config) + return created_config - def create_workspace_id(self) -> str: + def create_workspace_id(self, *, config: PortfolioConfig | None = None) -> str: workspace_id = str(uuid4()) - self.create_workspace(workspace_id) + self.create_workspace(workspace_id, config=config) return workspace_id def load_portfolio_config(self, workspace_id: str) -> PortfolioConfig: diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 12b51fc..a6a26a0 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -75,10 +75,24 @@ def test_root_without_workspace_cookie_shows_welcome_page(tmp_path, monkeypatch) def test_bootstrap_endpoint_requires_turnstile_and_creates_workspace_cookie_and_redirects( tmp_path, monkeypatch ) -> None: + from app import main as main_module from app.services import turnstile as turnstile_module + class _QuoteDataService: + default_symbol = "GLD" + + async def get_quote(self, symbol: str) -> dict[str, object]: + return { + "symbol": symbol, + "price": 404.19, + "quote_unit": "share", + "source": "test", + "updated_at": "2026-03-25T00:00:00+00:00", + } + repo = _install_workspace_repo(tmp_path, monkeypatch) monkeypatch.setattr(turnstile_module, "verify_turnstile_token", lambda *args, **kwargs: True) + monkeypatch.setattr(main_module, "get_data_service", lambda: _QuoteDataService()) with TestClient(app) as client: response = client.post( @@ -94,6 +108,11 @@ def test_bootstrap_endpoint_requires_turnstile_and_creates_workspace_cookie_and_ assert repo.workspace_exists(workspace_id) assert response.cookies.get("workspace_id") == workspace_id + created = repo.load_portfolio_config(workspace_id) + assert created.entry_price == 4041.9 + assert created.gold_ounces == 100.0 + assert created.gold_value == 404190.0 + def test_root_with_valid_workspace_cookie_redirects_to_workspace(tmp_path, monkeypatch) -> None: repo = _install_workspace_repo(tmp_path, monkeypatch)