fix(workspaces): seed new defaults from live quote

This commit is contained in:
Bu5hm4nn
2026-03-25 19:48:58 +01:00
parent 782e8f692e
commit aae67dfd9b
5 changed files with 77 additions and 17 deletions

View File

@@ -159,13 +159,11 @@ def strategy_benefit_per_unit(strategy: Mapping[str, Any], *, current_spot: floa
return round(float(benefit), 2) return round(float(benefit), 2)
def resolve_portfolio_spot_from_quote( def resolve_collateral_spot_from_quote(
config: PortfolioConfig,
quote: Mapping[str, object], quote: Mapping[str, object],
*, *,
fallback_symbol: str | None = None, fallback_symbol: str | None = None,
) -> tuple[float, str, str]: ) -> tuple[float, str, str] | None:
configured_price = float(config.entry_price or 0.0)
quote_price = _safe_quote_price(quote.get("price")) quote_price = _safe_quote_price(quote.get("price"))
quote_source = str(quote.get("source", "unknown")) quote_source = str(quote.get("source", "unknown"))
quote_updated_at = str(quote.get("updated_at", "")) 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() quote_unit = str(quote.get("quote_unit", "")).strip().lower()
if quote_price <= 0 or not quote_symbol or quote_unit != "share": if quote_price <= 0 or not quote_symbol or quote_unit != "share":
return configured_price, "configured_entry_price", "" return None
try: try:
metadata = instrument_metadata(quote_symbol) metadata = instrument_metadata(quote_symbol)
except ValueError: except ValueError:
return configured_price, "configured_entry_price", "" return None
converted_spot = metadata.price_per_weight_from_asset_price( converted_spot = metadata.price_per_weight_from_asset_price(
PricePerAsset(amount=decimal_from_float(quote_price), currency=BaseCurrency.USD, symbol=quote_symbol), 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 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( def portfolio_snapshot_from_config(
config: PortfolioConfig | None = None, config: PortfolioConfig | None = None,
*, *,

View File

@@ -17,11 +17,13 @@ from nicegui import ui # type: ignore[attr-defined]
import app.pages # noqa: F401 import app.pages # noqa: F401
from app.api.routes import router as api_router 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.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.services import turnstile as turnstile_service from app.services import turnstile as turnstile_service
from app.services.cache import CacheService from app.services.cache import CacheService
from app.services.data_service import DataService 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")) logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -169,7 +171,18 @@ async def bootstrap_workspace(
): ):
return RedirectResponse(url="/?captcha_error=1", status_code=303) 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 = RedirectResponse(url=f"/{workspace_id}", status_code=303)
response.set_cookie( response.set_cookie(
key=WORKSPACE_COOKIE, key=WORKSPACE_COOKIE,

View File

@@ -16,6 +16,17 @@ _DEFAULT_GOLD_OUNCES = 100.0
_LEGACY_DEFAULT_GOLD_OUNCES = 1_000.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) @dataclass(frozen=True)
class LombardPortfolio: class LombardPortfolio:
"""Lombard loan portfolio backed by physical gold.""" """Lombard loan portfolio backed by physical gold."""
@@ -122,8 +133,9 @@ class PortfolioConfig:
raise ValueError("Gold weight must be positive") raise ValueError("Gold weight must be positive")
if self.gold_value is None and self.gold_ounces is None: if self.gold_value is None and self.gold_ounces is None:
self.gold_value = _DEFAULT_GOLD_VALUE default = build_default_portfolio_config(entry_price=self.entry_price)
self.gold_ounces = self.gold_value / self.entry_price self.gold_value = default.gold_value
self.gold_ounces = default.gold_ounces
return return
if self.gold_value is None and self.gold_ounces is not None: if self.gold_value is None and self.gold_ounces is not None:

View File

@@ -4,7 +4,7 @@ import re
from pathlib import Path from pathlib import Path
from uuid import uuid4 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_COOKIE = "workspace_id"
_WORKSPACE_ID_RE = re.compile( _WORKSPACE_ID_RE = re.compile(
@@ -35,17 +35,22 @@ class WorkspaceRepository:
return False return False
return True 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()) resolved_workspace_id = workspace_id or str(uuid4())
if not self.is_valid_workspace_id(resolved_workspace_id): if not self.is_valid_workspace_id(resolved_workspace_id):
raise ValueError("workspace_id must be a UUID4 string") raise ValueError("workspace_id must be a UUID4 string")
config = PortfolioConfig() created_config = config or build_default_portfolio_config()
self.save_portfolio_config(resolved_workspace_id, config) self.save_portfolio_config(resolved_workspace_id, created_config)
return config return created_config
def create_workspace_id(self) -> str: def create_workspace_id(self, *, config: PortfolioConfig | None = None) -> str:
workspace_id = str(uuid4()) workspace_id = str(uuid4())
self.create_workspace(workspace_id) self.create_workspace(workspace_id, config=config)
return workspace_id return workspace_id
def load_portfolio_config(self, workspace_id: str) -> PortfolioConfig: def load_portfolio_config(self, workspace_id: str) -> PortfolioConfig:

View File

@@ -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( def test_bootstrap_endpoint_requires_turnstile_and_creates_workspace_cookie_and_redirects(
tmp_path, monkeypatch tmp_path, monkeypatch
) -> None: ) -> None:
from app import main as main_module
from app.services import turnstile as turnstile_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) repo = _install_workspace_repo(tmp_path, monkeypatch)
monkeypatch.setattr(turnstile_module, "verify_turnstile_token", lambda *args, **kwargs: True) 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: with TestClient(app) as client:
response = client.post( 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 repo.workspace_exists(workspace_id)
assert response.cookies.get("workspace_id") == 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: def test_root_with_valid_workspace_cookie_redirects_to_workspace(tmp_path, monkeypatch) -> None:
repo = _install_workspace_repo(tmp_path, monkeypatch) repo = _install_workspace_repo(tmp_path, monkeypatch)