fix(workspaces): seed new defaults from live quote
This commit is contained in:
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
17
app/main.py
17
app/main.py
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user