diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 312a1ed..27651a7 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -9,6 +9,12 @@ from decimal import Decimal from pathlib import Path from typing import Any +_DEFAULT_GOLD_VALUE = 215_000.0 +_DEFAULT_ENTRY_PRICE = 2_150.0 +_LEGACY_DEFAULT_ENTRY_PRICE = 215.0 +_DEFAULT_GOLD_OUNCES = 100.0 +_LEGACY_DEFAULT_GOLD_OUNCES = 1_000.0 + @dataclass(frozen=True) class LombardPortfolio: @@ -79,7 +85,7 @@ class PortfolioConfig: """ gold_value: float | None = None - entry_price: float | None = 215.0 + entry_price: float | None = _DEFAULT_ENTRY_PRICE gold_ounces: float | None = None entry_basis_mode: str = "value_price" loan_amount: float = 145000.0 @@ -116,7 +122,7 @@ class PortfolioConfig: raise ValueError("Gold weight must be positive") if self.gold_value is None and self.gold_ounces is None: - self.gold_value = 215000.0 + self.gold_value = _DEFAULT_GOLD_VALUE self.gold_ounces = self.gold_value / self.entry_price return @@ -322,7 +328,9 @@ class PortfolioRepository: 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)) + deserialized = cls._deserialize_portfolio_payload(portfolio) + upgraded = cls._upgrade_legacy_default_workspace(deserialized) + return PortfolioConfig.from_dict(upgraded) @classmethod def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None: @@ -341,6 +349,39 @@ class PortfolioRepository: 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 _upgrade_legacy_default_workspace(cls, payload: dict[str, Any]) -> dict[str, Any]: + if not cls._looks_like_legacy_default_workspace(payload): + return payload + upgraded = dict(payload) + upgraded["entry_price"] = _DEFAULT_ENTRY_PRICE + upgraded["gold_ounces"] = _DEFAULT_GOLD_OUNCES + upgraded["gold_value"] = _DEFAULT_GOLD_VALUE + return upgraded + + @staticmethod + def _looks_like_legacy_default_workspace(payload: dict[str, Any]) -> bool: + def _close(key: str, expected: float) -> bool: + value = payload.get(key) + return isinstance(value, (int, float)) and abs(float(value) - expected) <= 1e-9 + + return ( + _close("gold_value", _DEFAULT_GOLD_VALUE) + and _close("entry_price", _LEGACY_DEFAULT_ENTRY_PRICE) + and _close("gold_ounces", _LEGACY_DEFAULT_GOLD_OUNCES) + and payload.get("entry_basis_mode") == "value_price" + and _close("loan_amount", 145_000.0) + and _close("margin_threshold", 0.75) + and _close("monthly_budget", 8_000.0) + and _close("ltv_warning", 0.70) + and payload.get("primary_source") == "yfinance" + and payload.get("fallback_source") == "yfinance" + and payload.get("refresh_interval") == 5 + and _close("volatility_spike", 0.25) + and _close("spot_drawdown", 7.5) + and payload.get("email_alerts") is False + ) + @classmethod def _serialize_value(cls, key: str, value: Any) -> Any: if key in cls._MONEY_FIELDS: diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index c11c60e..ab9e71b 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -156,6 +156,47 @@ def test_portfolio_repository_rejects_unsupported_schema_version(tmp_path) -> No PortfolioRepository(config_path=config_path).load() +def test_portfolio_config_defaults_to_100_ounces() -> None: + config = PortfolioConfig() + + assert config.gold_value == pytest.approx(215_000.0, rel=1e-12) + assert config.entry_price == pytest.approx(2_150.0, rel=1e-12) + assert config.gold_ounces == pytest.approx(100.0, rel=1e-12) + + +def test_portfolio_repository_upgrades_legacy_default_workspace_footprint(tmp_path) -> None: + config_path = tmp_path / "portfolio_config.json" + config_path.write_text( + json.dumps( + { + "schema_version": 2, + "portfolio": { + "gold_value": {"value": "215000.0", "currency": "USD"}, + "entry_price": {"value": "215.0", "currency": "USD", "per_weight_unit": "ozt"}, + "gold_ounces": {"value": "1000.0", "unit": "ozt"}, + "entry_basis_mode": "value_price", + "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, + }, + } + ) + ) + + config = PortfolioRepository(config_path=config_path).load() + + assert config.gold_value == pytest.approx(215_000.0, rel=1e-12) + assert config.entry_price == pytest.approx(2_150.0, rel=1e-12) + assert config.gold_ounces == pytest.approx(100.0, rel=1e-12) + + def test_portfolio_repository_rejects_non_integer_refresh_interval_value(tmp_path) -> None: repo = PortfolioRepository(config_path=tmp_path / "portfolio_config.json") config = PortfolioConfig() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 707253a..12b51fc 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -254,3 +254,58 @@ def test_hedge_page_upgrades_cached_gld_quote_and_uses_converted_spot(tmp_path, assert portfolio["spot_price"] == 4041.9 assert portfolio["gold_value"] == 889218.0 assert portfolio["net_equity"] == 667218.0 + + +def test_hedge_page_upgrades_legacy_default_workspace_footprint(tmp_path, monkeypatch) -> None: + import asyncio + + from app.pages import hedge as hedge_module + from app.services import runtime as runtime_module + + _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( + { + "schema_version": 2, + "portfolio": { + "gold_value": {"value": "215000.0", "currency": "USD"}, + "entry_price": {"value": "215.0", "currency": "USD", "per_weight_unit": "ozt"}, + "gold_ounces": {"value": "1000.0", "unit": "ozt"}, + "entry_basis_mode": "value_price", + "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, + }, + } + ) + ) + + class _CacheStub: + async def get_json(self, key: str): # type: ignore[override] + if key == "quote:GLD": + return {"symbol": "GLD", "price": 404.19, "quote_unit": "share", "source": "cache"} + return None + + async def set_json(self, key: str, value): # type: ignore[override] + return True + + data_service = DataService(cache=_CacheStub()) # type: ignore[arg-type] + monkeypatch.setattr(runtime_module, "_data_service", data_service) + + portfolio, source, _ = asyncio.run(hedge_module._resolve_hedge_spot(workspace_id)) + + assert source == "cache" + assert portfolio["gold_units"] == 100.0 + assert portfolio["margin_call_price"] == 1933.3333333333333 + assert portfolio["gold_value"] == 404190.0 + assert portfolio["net_equity"] == 259190.0