fix(portfolio): default new workspaces to 100 oz
This commit is contained in:
@@ -9,6 +9,12 @@ from decimal import Decimal
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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)
|
@dataclass(frozen=True)
|
||||||
class LombardPortfolio:
|
class LombardPortfolio:
|
||||||
@@ -79,7 +85,7 @@ class PortfolioConfig:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
gold_value: float | None = None
|
gold_value: float | None = None
|
||||||
entry_price: float | None = 215.0
|
entry_price: float | None = _DEFAULT_ENTRY_PRICE
|
||||||
gold_ounces: float | None = None
|
gold_ounces: float | None = None
|
||||||
entry_basis_mode: str = "value_price"
|
entry_basis_mode: str = "value_price"
|
||||||
loan_amount: float = 145000.0
|
loan_amount: float = 145000.0
|
||||||
@@ -116,7 +122,7 @@ 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 = 215000.0
|
self.gold_value = _DEFAULT_GOLD_VALUE
|
||||||
self.gold_ounces = self.gold_value / self.entry_price
|
self.gold_ounces = self.gold_value / self.entry_price
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -322,7 +328,9 @@ class PortfolioRepository:
|
|||||||
if not isinstance(portfolio, dict):
|
if not isinstance(portfolio, dict):
|
||||||
raise TypeError("portfolio payload must be an object")
|
raise TypeError("portfolio payload must be an object")
|
||||||
cls._validate_portfolio_fields(portfolio)
|
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
|
@classmethod
|
||||||
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:
|
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]:
|
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()}
|
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
|
@classmethod
|
||||||
def _serialize_value(cls, key: str, value: Any) -> Any:
|
def _serialize_value(cls, key: str, value: Any) -> Any:
|
||||||
if key in cls._MONEY_FIELDS:
|
if key in cls._MONEY_FIELDS:
|
||||||
|
|||||||
@@ -156,6 +156,47 @@ def test_portfolio_repository_rejects_unsupported_schema_version(tmp_path) -> No
|
|||||||
PortfolioRepository(config_path=config_path).load()
|
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:
|
def test_portfolio_repository_rejects_non_integer_refresh_interval_value(tmp_path) -> None:
|
||||||
repo = PortfolioRepository(config_path=tmp_path / "portfolio_config.json")
|
repo = PortfolioRepository(config_path=tmp_path / "portfolio_config.json")
|
||||||
config = PortfolioConfig()
|
config = PortfolioConfig()
|
||||||
|
|||||||
@@ -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["spot_price"] == 4041.9
|
||||||
assert portfolio["gold_value"] == 889218.0
|
assert portfolio["gold_value"] == 889218.0
|
||||||
assert portfolio["net_equity"] == 667218.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
|
||||||
|
|||||||
Reference in New Issue
Block a user