From 132aaed51293daba8aea2c89203938835f071476 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Wed, 25 Mar 2026 13:19:33 +0100 Subject: [PATCH] feat(CORE-001D1): harden unit-aware workspace persistence --- app/models/portfolio.py | 235 +++++++++++++++++- app/models/workspace.py | 9 +- app/pages/backtests.py | 12 +- app/pages/common.py | 6 +- app/pages/event_comparison.py | 12 +- app/pages/hedge.py | 12 +- app/pages/overview.py | 9 - app/pages/settings.py | 12 +- docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md | 13 +- .../CORE-001D-decimal-boundary-cleanup.yaml | 1 + tests/test_e2e_playwright.py | 9 +- tests/test_portfolio.py | 170 ++++++++++++- tests/test_workspace.py | 51 +++- 13 files changed, 464 insertions(+), 87 deletions(-) diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 41d5b2d..312a1ed 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -3,7 +3,9 @@ from __future__ import annotations import json +import os from dataclasses import dataclass +from decimal import Decimal from pathlib import Path from typing import Any @@ -214,6 +216,24 @@ class PortfolioConfig: return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) +def _coerce_persisted_decimal(value: Any) -> Decimal: + if isinstance(value, bool): + raise TypeError("Boolean values are not valid decimal persistence inputs") + if isinstance(value, Decimal): + amount = value + elif isinstance(value, int): + amount = Decimal(value) + elif isinstance(value, float): + amount = Decimal(str(value)) + elif isinstance(value, str): + amount = Decimal(value) + else: + raise TypeError(f"Unsupported persisted decimal input type: {type(value)!r}") + if not amount.is_finite(): + raise ValueError("Decimal persistence value must be finite") + return amount + + class PortfolioRepository: """Repository for persisting portfolio configuration. @@ -221,6 +241,36 @@ class PortfolioRepository: """ CONFIG_PATH = Path("data/portfolio_config.json") + SCHEMA_VERSION = 2 + PERSISTENCE_CURRENCY = "USD" + PERSISTENCE_WEIGHT_UNIT = "ozt" + _WEIGHT_FACTORS = { + "g": Decimal("1"), + "kg": Decimal("1000"), + "ozt": Decimal("31.1034768"), + } + _MONEY_FIELDS = {"gold_value", "loan_amount", "monthly_budget"} + _WEIGHT_FIELDS = {"gold_ounces"} + _PRICE_PER_WEIGHT_FIELDS = {"entry_price"} + _RATIO_FIELDS = {"margin_threshold", "ltv_warning", "volatility_spike"} + _PERCENT_FIELDS = {"spot_drawdown"} + _INTEGER_FIELDS = {"refresh_interval"} + _PERSISTED_FIELDS = { + "gold_value", + "entry_price", + "gold_ounces", + "entry_basis_mode", + "loan_amount", + "margin_threshold", + "monthly_budget", + "ltv_warning", + "primary_source", + "fallback_source", + "refresh_interval", + "volatility_spike", + "spot_drawdown", + "email_alerts", + } def __init__(self, config_path: Path | None = None) -> None: self.config_path = config_path or self.CONFIG_PATH @@ -228,8 +278,13 @@ class PortfolioRepository: def save(self, config: PortfolioConfig) -> None: """Save configuration to disk.""" - with open(self.config_path, "w") as f: - json.dump(config.to_dict(), f, indent=2) + payload = self._to_persistence_payload(config) + tmp_path = self.config_path.with_name(f"{self.config_path.name}.tmp") + with open(tmp_path, "w") as f: + json.dump(payload, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.config_path) def load(self) -> PortfolioConfig: """Load configuration from disk. @@ -244,10 +299,178 @@ class PortfolioRepository: try: with open(self.config_path) as f: data = json.load(f) - return PortfolioConfig.from_dict(data) - except (json.JSONDecodeError, ValueError) as e: - print(f"Warning: Failed to load portfolio config: {e}. Using defaults.") - return PortfolioConfig() + except json.JSONDecodeError as e: + raise ValueError(f"Invalid portfolio config JSON: {e}") from e + + return self._config_from_payload(data) + + @classmethod + def _to_persistence_payload(cls, config: PortfolioConfig) -> dict[str, Any]: + return { + "schema_version": cls.SCHEMA_VERSION, + "portfolio": {key: cls._serialize_value(key, value) for key, value in config.to_dict().items()}, + } + + @classmethod + def _config_from_payload(cls, data: dict[str, Any]) -> PortfolioConfig: + if not isinstance(data, dict): + raise TypeError("portfolio config payload must be an object") + schema_version = data.get("schema_version") + if schema_version != cls.SCHEMA_VERSION: + raise ValueError(f"Unsupported portfolio schema_version: {schema_version}") + portfolio = data.get("portfolio") + if not isinstance(portfolio, dict): + raise TypeError("portfolio payload must be an object") + cls._validate_portfolio_fields(portfolio) + return PortfolioConfig.from_dict(cls._deserialize_portfolio_payload(portfolio)) + + @classmethod + def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None: + keys = set(payload.keys()) + missing = sorted(cls._PERSISTED_FIELDS - keys) + unknown = sorted(keys - cls._PERSISTED_FIELDS) + if missing or unknown: + details: list[str] = [] + if missing: + details.append(f"missing={missing}") + if unknown: + details.append(f"unknown={unknown}") + raise ValueError(f"Invalid portfolio payload fields: {'; '.join(details)}") + + @classmethod + def _deserialize_portfolio_payload(cls, payload: dict[str, Any]) -> dict[str, Any]: + return {key: cls._deserialize_value(key, value) for key, value in payload.items()} + + @classmethod + def _serialize_value(cls, key: str, value: Any) -> Any: + if key in cls._MONEY_FIELDS: + return {"value": cls._decimal_to_string(value), "currency": cls.PERSISTENCE_CURRENCY} + if key in cls._WEIGHT_FIELDS: + return {"value": cls._decimal_to_string(value), "unit": cls.PERSISTENCE_WEIGHT_UNIT} + if key in cls._PRICE_PER_WEIGHT_FIELDS: + return { + "value": cls._decimal_to_string(value), + "currency": cls.PERSISTENCE_CURRENCY, + "per_weight_unit": cls.PERSISTENCE_WEIGHT_UNIT, + } + if key in cls._RATIO_FIELDS: + return {"value": cls._decimal_to_string(value), "unit": "ratio"} + if key in cls._PERCENT_FIELDS: + return {"value": cls._decimal_to_string(value), "unit": "percent"} + if key in cls._INTEGER_FIELDS: + return cls._serialize_integer(value, unit="seconds") + return value + + @classmethod + def _deserialize_value(cls, key: str, value: Any) -> Any: + if key in cls._MONEY_FIELDS: + return float(cls._deserialize_money(value)) + if key in cls._WEIGHT_FIELDS: + return float(cls._deserialize_weight(value)) + if key in cls._PRICE_PER_WEIGHT_FIELDS: + return float(cls._deserialize_price_per_weight(value)) + if key in cls._RATIO_FIELDS: + return float(cls._deserialize_ratio(value)) + if key in cls._PERCENT_FIELDS: + return float(cls._deserialize_percent(value)) + if key in cls._INTEGER_FIELDS: + return cls._deserialize_integer(value, expected_unit="seconds") + return value + + @classmethod + def _deserialize_money(cls, value: Any) -> Decimal: + if not isinstance(value, dict): + raise TypeError("money field must be an object") + currency = value.get("currency") + if currency != cls.PERSISTENCE_CURRENCY: + raise ValueError(f"Unsupported currency: {currency!r}") + return _coerce_persisted_decimal(value.get("value")) + + @classmethod + def _deserialize_weight(cls, value: Any) -> Decimal: + if not isinstance(value, dict): + raise TypeError("weight field must be an object") + amount = _coerce_persisted_decimal(value.get("value")) + unit = value.get("unit") + return cls._convert_weight(amount, unit, cls.PERSISTENCE_WEIGHT_UNIT) + + @classmethod + def _deserialize_price_per_weight(cls, value: Any) -> Decimal: + if not isinstance(value, dict): + raise TypeError("price-per-weight field must be an object") + currency = value.get("currency") + if currency != cls.PERSISTENCE_CURRENCY: + raise ValueError(f"Unsupported currency: {currency!r}") + amount = _coerce_persisted_decimal(value.get("value")) + unit = value.get("per_weight_unit") + return cls._convert_price_per_weight(amount, unit, cls.PERSISTENCE_WEIGHT_UNIT) + + @classmethod + def _deserialize_ratio(cls, value: Any) -> Decimal: + if not isinstance(value, dict): + raise TypeError("ratio field must be an object") + amount = _coerce_persisted_decimal(value.get("value")) + unit = value.get("unit") + if unit == "ratio": + return amount + if unit == "percent": + return amount / Decimal("100") + raise ValueError(f"Unsupported ratio unit: {unit!r}") + + @classmethod + def _deserialize_percent(cls, value: Any) -> Decimal: + if not isinstance(value, dict): + raise TypeError("percent field must be an object") + amount = _coerce_persisted_decimal(value.get("value")) + unit = value.get("unit") + if unit == "percent": + return amount + if unit == "ratio": + return amount * Decimal("100") + raise ValueError(f"Unsupported percent unit: {unit!r}") + + @staticmethod + def _serialize_integer(value: Any, *, unit: str) -> dict[str, Any]: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("integer field value must be an int") + return {"value": value, "unit": unit} + + @staticmethod + def _deserialize_integer(value: Any, *, expected_unit: str) -> int: + if not isinstance(value, dict): + raise TypeError("integer field must be an object") + unit = value.get("unit") + if unit != expected_unit: + raise ValueError(f"Unsupported integer unit: {unit!r}") + raw = value.get("value") + if isinstance(raw, bool) or not isinstance(raw, int): + raise TypeError("integer field value must be an int") + return raw + + @classmethod + def _convert_weight(cls, amount: Decimal, from_unit: Any, to_unit: str) -> Decimal: + if from_unit not in cls._WEIGHT_FACTORS or to_unit not in cls._WEIGHT_FACTORS: + raise ValueError(f"Unsupported weight unit conversion: {from_unit!r} -> {to_unit!r}") + if from_unit == to_unit: + return amount + grams = amount * cls._WEIGHT_FACTORS[from_unit] + return grams / cls._WEIGHT_FACTORS[to_unit] + + @classmethod + def _convert_price_per_weight(cls, amount: Decimal, from_unit: Any, to_unit: str) -> Decimal: + if from_unit not in cls._WEIGHT_FACTORS or to_unit not in cls._WEIGHT_FACTORS: + raise ValueError(f"Unsupported price-per-weight unit conversion: {from_unit!r} -> {to_unit!r}") + if from_unit == to_unit: + return amount + return amount * cls._WEIGHT_FACTORS[to_unit] / cls._WEIGHT_FACTORS[from_unit] + + @staticmethod + def _decimal_to_string(value: Any) -> str: + decimal_value = _coerce_persisted_decimal(value) + normalized = format(decimal_value, "f") + if "." not in normalized: + normalized = f"{normalized}.0" + return normalized _portfolio_repo: PortfolioRepository | None = None diff --git a/app/models/workspace.py b/app/models/workspace.py index f4806f0..f9b5756 100644 --- a/app/models/workspace.py +++ b/app/models/workspace.py @@ -26,7 +26,14 @@ class WorkspaceRepository: def workspace_exists(self, workspace_id: str) -> bool: if not self.is_valid_workspace_id(workspace_id): return False - return self._portfolio_path(workspace_id).exists() + portfolio_path = self._portfolio_path(workspace_id) + if not portfolio_path.exists(): + return False + try: + PortfolioRepository(portfolio_path).load() + except (ValueError, TypeError, FileNotFoundError): + return False + return True def create_workspace(self, workspace_id: str | None = None) -> PortfolioConfig: resolved_workspace_id = workspace_id or str(uuid4()) diff --git a/app/pages/backtests.py b/app/pages/backtests.py index 60e07e7..01d796b 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -2,12 +2,11 @@ from __future__ import annotations from datetime import date, datetime -from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui from app.domain.backtesting_math import asset_quantity_from_floats -from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository +from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page, render_workspace_recovery from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService @@ -50,15 +49,6 @@ def _chart_options(result: BacktestPageRunResult) -> dict: } -@ui.page("/backtests") -def legacy_backtests_page(request: Request): - repo = get_workspace_repository() - workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") - if workspace_id and repo.workspace_exists(workspace_id): - return RedirectResponse(url=f"/{workspace_id}/backtests", status_code=307) - _render_backtests_page() - - @ui.page("/{workspace_id}/backtests") def workspace_backtests_page(workspace_id: str) -> None: repo = get_workspace_repository() diff --git a/app/pages/common.py b/app/pages/common.py index a5502e5..0d46703 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -11,12 +11,8 @@ from app.models.portfolio import PortfolioConfig from app.services.strategy_templates import StrategyTemplateService NAV_ITEMS: list[tuple[str, str, str]] = [ - ("overview", "/", "Overview"), - ("hedge", "/hedge", "Hedge Analysis"), + ("welcome", "/", "Welcome"), ("options", "/options", "Options Chain"), - ("backtests", "/backtests", "Backtests"), - ("event-comparison", "/event-comparison", "Event Comparison"), - ("settings", "/settings", "Settings"), ] diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index 281e260..4db4a70 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -1,11 +1,10 @@ from __future__ import annotations -from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui from app.domain.backtesting_math import asset_quantity_from_floats -from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository +from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page, render_workspace_recovery from app.services.event_comparison_ui import EventComparisonPageService @@ -28,15 +27,6 @@ def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...] } -@ui.page("/event-comparison") -def legacy_event_comparison_page(request: Request): - repo = get_workspace_repository() - workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") - if workspace_id and repo.workspace_exists(workspace_id): - return RedirectResponse(url=f"/{workspace_id}/event-comparison", status_code=307) - _render_event_comparison_page() - - @ui.page("/{workspace_id}/event-comparison") def workspace_event_comparison_page(workspace_id: str) -> None: repo = get_workspace_repository() diff --git a/app/pages/hedge.py b/app/pages/hedge.py index b09e666..fa82974 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -1,10 +1,9 @@ from __future__ import annotations -from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui -from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository +from app.models.workspace import get_workspace_repository from app.pages.common import ( dashboard_page, demo_spot_price, @@ -57,15 +56,6 @@ def _waterfall_options(metrics: dict) -> dict: } -@ui.page("/hedge") -def legacy_hedge_page(request: Request): - repo = get_workspace_repository() - workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") - if workspace_id and repo.workspace_exists(workspace_id): - return RedirectResponse(url=f"/{workspace_id}/hedge", status_code=307) - _render_hedge_page() - - @ui.page("/{workspace_id}/hedge") def workspace_hedge_page(workspace_id: str) -> None: repo = get_workspace_repository() diff --git a/app/pages/overview.py b/app/pages/overview.py index b67ba0a..96c1a6a 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -105,15 +105,6 @@ def welcome_page(request: Request): ) -@ui.page("/overview") -def legacy_overview_page(request: Request): - repo = get_workspace_repository() - workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") - if workspace_id and repo.workspace_exists(workspace_id): - return RedirectResponse(url=f"/{workspace_id}", status_code=307) - return RedirectResponse(url="/", status_code=307) - - @ui.page("/{workspace_id}") @ui.page("/{workspace_id}/overview") async def overview_page(workspace_id: str) -> None: diff --git a/app/pages/settings.py b/app/pages/settings.py index 5511f27..4bd1405 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -1,11 +1,10 @@ from __future__ import annotations -from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui from app.models.portfolio import PortfolioConfig -from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository +from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.settings_status import save_status_text @@ -35,15 +34,6 @@ def _render_workspace_recovery() -> None: ) -@ui.page("/settings") -def legacy_settings_page(request: Request): - repo = get_workspace_repository() - workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") - if workspace_id and repo.workspace_exists(workspace_id): - return RedirectResponse(url=f"/{workspace_id}/settings", status_code=307) - return RedirectResponse(url="/", status_code=307) - - @ui.page("/{workspace_id}/settings") def settings_page(workspace_id: str) -> None: """Settings page with workspace-scoped persistent portfolio configuration.""" diff --git a/docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md b/docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md index 8b85e1b..b4a0b59 100644 --- a/docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md +++ b/docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md @@ -108,9 +108,6 @@ Deliverables: - replacing every float in every Pydantic/dataclass immediately - redesigning third-party payload models wholesale - changing public UI formatting behavior just for type purity -- solving instrument-level share-to-underlying conversion semantics such as `GLD share = 0.1 ozt` inside this slice alone - -That broader instrument-aware unit problem is tracked separately in `CORE-002`. ## First candidate sub-slices @@ -130,3 +127,13 @@ That broader instrument-aware unit problem is tracked separately in `CORE-002`. - Decimal/unit-safe values cross boundaries through named adapters - remaining float-heavy hotspots are either removed or intentionally documented as edge-only - no regression in existing browser-visible flows + +## Pre-launch rollout policy +For the current pre-launch stage, the storage schema may make a clean breaking transition. + +That means: +- newly persisted numeric domain values should use explicit structured unit-aware storage +- old flat storage payloads do not need compatibility or migration yet +- invalid or old-format payloads should fail loudly instead of being silently normalized + +A real migration path should be introduced later, once persistence is considered live for users. diff --git a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml index 14808f0..e563641 100644 --- a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml +++ b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml @@ -15,4 +15,5 @@ acceptance_criteria: - Remaining raw-float domain hotspots are identified or removed. technical_notes: - Prioritize portfolio/workspace persistence, provider normalization, and cache serialization seams. + - Pre-launch policy: unit-aware schema changes may be breaking until persistence is considered live; old flat payloads may fail loudly instead of being migrated. - See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices. diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 61e06d8..c76700d 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -35,8 +35,7 @@ def test_homepage_and_options_page_render() -> None: page.wait_for_url(workspace_url, timeout=15000) expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) - page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000) - page.wait_for_url(f"{workspace_url}/backtests", timeout=15000) + page.goto(f"{workspace_url}/backtests", wait_until="networkidle", timeout=30000) expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000) @@ -58,8 +57,7 @@ def test_homepage_and_options_page_render() -> None: assert "RuntimeError" not in rerun_text assert "Server error" not in rerun_text - page.goto(f"{BASE_URL}/event-comparison", wait_until="networkidle", timeout=30000) - page.wait_for_url(f"{workspace_url}/event-comparison", timeout=15000) + page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000) expect(page.locator("text=Event Comparison").first).to_be_visible(timeout=15000) expect(page.locator("text=Comparison Form").first).to_be_visible(timeout=15000) expect(page.locator("text=Ranked Results").first).to_be_visible(timeout=15000) @@ -166,8 +164,7 @@ def test_homepage_and_options_page_render() -> None: second_page.close() second_context.close() - page.goto(f"{BASE_URL}/hedge", wait_until="domcontentloaded", timeout=30000) - page.wait_for_url(f"{workspace_url}/hedge", timeout=15000) + page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy Controls").first).to_be_visible(timeout=15000) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index af76ded..c11c60e 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -1,8 +1,10 @@ from __future__ import annotations +import json + import pytest -from app.models.portfolio import PortfolioConfig +from app.models.portfolio import PortfolioConfig, PortfolioRepository def test_ltv_calculation(sample_portfolio) -> None: @@ -59,14 +61,6 @@ def test_portfolio_config_serializes_canonical_entry_basis_fields() -> None: 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) @@ -76,3 +70,161 @@ def test_portfolio_config_rejects_invalid_entry_basis_values() -> None: 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) + + +def test_portfolio_repository_persists_explicit_schema_metadata(tmp_path) -> None: + repo = PortfolioRepository(config_path=tmp_path / "portfolio_config.json") + config = PortfolioConfig( + gold_ounces=220.0, + entry_price=4400.0, + entry_basis_mode="weight", + loan_amount=145000.0, + margin_threshold=0.75, + monthly_budget=8000.0, + ltv_warning=0.70, + ) + + repo.save(config) + + payload = json.loads((tmp_path / "portfolio_config.json").read_text()) + + assert payload["schema_version"] == 2 + assert payload["portfolio"]["gold_value"] == {"value": "968000.0", "currency": "USD"} + assert payload["portfolio"]["entry_price"] == { + "value": "4400.0", + "currency": "USD", + "per_weight_unit": "ozt", + } + assert payload["portfolio"]["gold_ounces"] == {"value": "220.0", "unit": "ozt"} + assert payload["portfolio"]["loan_amount"] == {"value": "145000.0", "currency": "USD"} + + +def test_portfolio_repository_loads_explicit_schema_payload_and_converts_units(tmp_path) -> None: + config_path = tmp_path / "portfolio_config.json" + config_path.write_text( + json.dumps( + { + "schema_version": 2, + "portfolio": { + "gold_value": {"value": "968000.0", "currency": "USD"}, + "entry_price": { + "value": "141.4632849019631", + "currency": "USD", + "per_weight_unit": "g", + }, + "gold_ounces": {"value": "6.842764896", "unit": "kg"}, + "entry_basis_mode": "weight", + "loan_amount": {"value": "145000.0", "currency": "USD"}, + "margin_threshold": {"value": "75.0", "unit": "percent"}, + "monthly_budget": {"value": "8000.0", "currency": "USD"}, + "ltv_warning": {"value": "70.0", "unit": "percent"}, + "primary_source": "yfinance", + "fallback_source": "yfinance", + "refresh_interval": {"value": 5, "unit": "seconds"}, + "volatility_spike": {"value": "25.0", "unit": "percent"}, + "spot_drawdown": {"value": "0.075", "unit": "ratio"}, + "email_alerts": False, + }, + } + ) + ) + + config = PortfolioRepository(config_path=config_path).load() + + assert config.gold_value == pytest.approx(968000.0, rel=1e-12) + assert config.entry_price == pytest.approx(4400.0, rel=1e-12) + assert config.gold_ounces == pytest.approx(220.0, rel=1e-12) + assert config.loan_amount == pytest.approx(145000.0, rel=1e-12) + assert config.margin_threshold == pytest.approx(0.75, rel=1e-12) + assert config.ltv_warning == pytest.approx(0.70, rel=1e-12) + assert config.volatility_spike == pytest.approx(0.25, rel=1e-12) + assert config.spot_drawdown == pytest.approx(7.5, rel=1e-12) + + +def test_portfolio_repository_rejects_unsupported_schema_version(tmp_path) -> None: + config_path = tmp_path / "portfolio_config.json" + config_path.write_text( + json.dumps( + { + "schema_version": 3, + "portfolio": {}, + } + ) + ) + + with pytest.raises(ValueError, match="Unsupported portfolio schema_version"): + PortfolioRepository(config_path=config_path).load() + + +def test_portfolio_repository_rejects_non_integer_refresh_interval_value(tmp_path) -> None: + repo = PortfolioRepository(config_path=tmp_path / "portfolio_config.json") + config = PortfolioConfig() + config.refresh_interval = 5.5 + + with pytest.raises(TypeError, match="integer field value must be an int"): + repo.save(config) + + +def test_portfolio_repository_failed_save_preserves_existing_config(tmp_path) -> None: + config_path = tmp_path / "portfolio_config.json" + repo = PortfolioRepository(config_path=config_path) + original = PortfolioConfig() + repo.save(original) + + broken = PortfolioConfig() + broken.refresh_interval = 5.5 + + with pytest.raises(TypeError, match="integer field value must be an int"): + repo.save(broken) + + payload = json.loads(config_path.read_text()) + assert payload["schema_version"] == 2 + assert payload["portfolio"]["refresh_interval"] == {"value": 5, "unit": "seconds"} + + +def test_portfolio_repository_rejects_incomplete_schema_payload(tmp_path) -> None: + config_path = tmp_path / "portfolio_config.json" + config_path.write_text( + json.dumps( + { + "schema_version": 2, + "portfolio": { + "gold_value": {"value": "968000.0", "currency": "USD"}, + "loan_amount": {"value": "145000.0", "currency": "USD"}, + }, + } + ) + ) + + with pytest.raises(ValueError, match="Invalid portfolio payload fields"): + PortfolioRepository(config_path=config_path).load() + + +def test_portfolio_repository_rejects_unsupported_field_units(tmp_path) -> None: + config_path = tmp_path / "portfolio_config.json" + config_path.write_text( + json.dumps( + { + "schema_version": 2, + "portfolio": { + "gold_value": {"value": "968000.0", "currency": "EUR"}, + "entry_price": {"value": "4400.0", "currency": "USD", "per_weight_unit": "stone"}, + "gold_ounces": {"value": "220.0", "unit": "stone"}, + "entry_basis_mode": "weight", + "loan_amount": {"value": "145000.0", "currency": "USD"}, + "margin_threshold": {"value": "0.75", "unit": "ratio"}, + "monthly_budget": {"value": "8000.0", "currency": "USD"}, + "ltv_warning": {"value": "0.70", "unit": "ratio"}, + "primary_source": "yfinance", + "fallback_source": "yfinance", + "refresh_interval": {"value": 5, "unit": "seconds"}, + "volatility_spike": {"value": "0.25", "unit": "ratio"}, + "spot_drawdown": {"value": "7.5", "unit": "percent"}, + "email_alerts": False, + }, + } + ) + ) + + with pytest.raises(ValueError, match="Unsupported currency|Unsupported .*unit"): + PortfolioRepository(config_path=config_path).load() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index ee56cd1..dd43e1b 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from uuid import uuid4 @@ -37,6 +38,28 @@ def test_workspace_repository_persists_workspace_specific_portfolio_config(tmp_p assert reloaded.gold_value == created.gold_value +def test_workspace_repository_writes_explicit_portfolio_schema(tmp_path) -> None: + repo = WorkspaceRepository(base_path=tmp_path / "workspaces") + workspace_id = str(uuid4()) + + config = repo.create_workspace(workspace_id) + config.entry_basis_mode = "weight" + config.entry_price = 4400.0 + config.gold_ounces = 220.0 + config.gold_value = 968000.0 + repo.save_portfolio_config(workspace_id, config) + + payload = json.loads((tmp_path / "workspaces" / workspace_id / "portfolio_config.json").read_text()) + + assert payload["schema_version"] == 2 + assert payload["portfolio"]["gold_ounces"] == {"value": "220.0", "unit": "ozt"} + assert payload["portfolio"]["entry_price"] == { + "value": "4400.0", + "currency": "USD", + "per_weight_unit": "ozt", + } + + def test_root_without_workspace_cookie_shows_welcome_page(tmp_path, monkeypatch) -> None: _install_workspace_repo(tmp_path, monkeypatch) @@ -93,6 +116,30 @@ def test_unknown_workspace_route_redirects_to_welcome_page(tmp_path, monkeypatch assert "Create a private workspace URL" in response.text +def test_invalid_workspace_config_is_not_treated_as_existing(tmp_path) -> None: + repo = WorkspaceRepository(base_path=tmp_path / "workspaces") + workspace_id = str(uuid4()) + workspace_path = tmp_path / "workspaces" / workspace_id + workspace_path.mkdir(parents=True, exist_ok=True) + (workspace_path / "portfolio_config.json").write_text(json.dumps({"gold_value": 215000.0})) + + assert repo.workspace_exists(workspace_id) is False + + +def test_invalid_workspace_config_route_redirects_to_welcome_page(tmp_path, monkeypatch) -> None: + _install_workspace_repo(tmp_path, monkeypatch) + workspace_id = str(uuid4()) + workspace_path = tmp_path / "workspaces" / workspace_id + workspace_path.mkdir(parents=True, exist_ok=True) + (workspace_path / "portfolio_config.json").write_text(json.dumps({"gold_value": 215000.0})) + + with TestClient(app) as client: + response = client.get(f"/{workspace_id}") + + assert response.status_code == 200 + assert "Create a private workspace URL" in response.text + + def test_arbitrary_fake_workspace_like_path_redirects_to_welcome_page(tmp_path, monkeypatch) -> None: _install_workspace_repo(tmp_path, monkeypatch) @@ -152,7 +199,6 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp hedge_response = client.get(f"/{workspace_id}/hedge") backtests_response = client.get(f"/{workspace_id}/backtests") event_response = client.get(f"/{workspace_id}/event-comparison") - redirect_response = client.get("/backtests", cookies={"workspace_id": workspace_id}, follow_redirects=False) assert hedge_response.status_code == 200 assert "Monthly hedge budget" in hedge_response.text @@ -176,6 +222,3 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp assert "222,000" in event_response.text or "222000" in event_response.text assert "9,680" in event_response.text or "9680" in event_response.text assert "80.0%" in event_response.text - - assert redirect_response.status_code in {302, 303, 307} - assert redirect_response.headers["location"] == f"/{workspace_id}/backtests"