feat(CORE-001D1): harden unit-aware workspace persistence

This commit is contained in:
Bu5hm4nn
2026-03-25 13:19:33 +01:00
parent cfb6abd842
commit 132aaed512
13 changed files with 464 additions and 87 deletions

View File

@@ -3,7 +3,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import Any 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__}) 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: class PortfolioRepository:
"""Repository for persisting portfolio configuration. """Repository for persisting portfolio configuration.
@@ -221,6 +241,36 @@ class PortfolioRepository:
""" """
CONFIG_PATH = Path("data/portfolio_config.json") 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: def __init__(self, config_path: Path | None = None) -> None:
self.config_path = config_path or self.CONFIG_PATH self.config_path = config_path or self.CONFIG_PATH
@@ -228,8 +278,13 @@ class PortfolioRepository:
def save(self, config: PortfolioConfig) -> None: def save(self, config: PortfolioConfig) -> None:
"""Save configuration to disk.""" """Save configuration to disk."""
with open(self.config_path, "w") as f: payload = self._to_persistence_payload(config)
json.dump(config.to_dict(), f, indent=2) 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: def load(self) -> PortfolioConfig:
"""Load configuration from disk. """Load configuration from disk.
@@ -244,10 +299,178 @@ class PortfolioRepository:
try: try:
with open(self.config_path) as f: with open(self.config_path) as f:
data = json.load(f) data = json.load(f)
return PortfolioConfig.from_dict(data) except json.JSONDecodeError as e:
except (json.JSONDecodeError, ValueError) as e: raise ValueError(f"Invalid portfolio config JSON: {e}") from e
print(f"Warning: Failed to load portfolio config: {e}. Using defaults.")
return PortfolioConfig() 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 _portfolio_repo: PortfolioRepository | None = None

View File

@@ -26,7 +26,14 @@ class WorkspaceRepository:
def workspace_exists(self, workspace_id: str) -> bool: def workspace_exists(self, workspace_id: str) -> bool:
if not self.is_valid_workspace_id(workspace_id): if not self.is_valid_workspace_id(workspace_id):
return False 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: def create_workspace(self, workspace_id: str | None = None) -> PortfolioConfig:
resolved_workspace_id = workspace_id or str(uuid4()) resolved_workspace_id = workspace_id or str(uuid4())

View File

@@ -2,12 +2,11 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from app.domain.backtesting_math import asset_quantity_from_floats 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.pages.common import dashboard_page, render_workspace_recovery
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService 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") @ui.page("/{workspace_id}/backtests")
def workspace_backtests_page(workspace_id: str) -> None: def workspace_backtests_page(workspace_id: str) -> None:
repo = get_workspace_repository() repo = get_workspace_repository()

View File

@@ -11,12 +11,8 @@ from app.models.portfolio import PortfolioConfig
from app.services.strategy_templates import StrategyTemplateService from app.services.strategy_templates import StrategyTemplateService
NAV_ITEMS: list[tuple[str, str, str]] = [ NAV_ITEMS: list[tuple[str, str, str]] = [
("overview", "/", "Overview"), ("welcome", "/", "Welcome"),
("hedge", "/hedge", "Hedge Analysis"),
("options", "/options", "Options Chain"), ("options", "/options", "Options Chain"),
("backtests", "/backtests", "Backtests"),
("event-comparison", "/event-comparison", "Event Comparison"),
("settings", "/settings", "Settings"),
] ]

View File

@@ -1,11 +1,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from app.domain.backtesting_math import asset_quantity_from_floats 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.pages.common import dashboard_page, render_workspace_recovery
from app.services.event_comparison_ui import EventComparisonPageService 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") @ui.page("/{workspace_id}/event-comparison")
def workspace_event_comparison_page(workspace_id: str) -> None: def workspace_event_comparison_page(workspace_id: str) -> None:
repo = get_workspace_repository() repo = get_workspace_repository()

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui 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 ( from app.pages.common import (
dashboard_page, dashboard_page,
demo_spot_price, 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") @ui.page("/{workspace_id}/hedge")
def workspace_hedge_page(workspace_id: str) -> None: def workspace_hedge_page(workspace_id: str) -> None:
repo = get_workspace_repository() repo = get_workspace_repository()

View File

@@ -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}")
@ui.page("/{workspace_id}/overview") @ui.page("/{workspace_id}/overview")
async def overview_page(workspace_id: str) -> None: async def overview_page(workspace_id: str) -> None:

View File

@@ -1,11 +1,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from app.models.portfolio import PortfolioConfig 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.pages.common import dashboard_page
from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.settings_status import save_status_text 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") @ui.page("/{workspace_id}/settings")
def settings_page(workspace_id: str) -> None: def settings_page(workspace_id: str) -> None:
"""Settings page with workspace-scoped persistent portfolio configuration.""" """Settings page with workspace-scoped persistent portfolio configuration."""

View File

@@ -108,9 +108,6 @@ Deliverables:
- replacing every float in every Pydantic/dataclass immediately - replacing every float in every Pydantic/dataclass immediately
- redesigning third-party payload models wholesale - redesigning third-party payload models wholesale
- changing public UI formatting behavior just for type purity - 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 ## 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 - Decimal/unit-safe values cross boundaries through named adapters
- remaining float-heavy hotspots are either removed or intentionally documented as edge-only - remaining float-heavy hotspots are either removed or intentionally documented as edge-only
- no regression in existing browser-visible flows - 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.

View File

@@ -15,4 +15,5 @@ acceptance_criteria:
- Remaining raw-float domain hotspots are identified or removed. - Remaining raw-float domain hotspots are identified or removed.
technical_notes: technical_notes:
- Prioritize portfolio/workspace persistence, provider normalization, and cache serialization seams. - 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. - See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices.

View File

@@ -35,8 +35,7 @@ def test_homepage_and_options_page_render() -> None:
page.wait_for_url(workspace_url, timeout=15000) page.wait_for_url(workspace_url, timeout=15000)
expect(page.locator("text=Alert Status").first).to_be_visible(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.goto(f"{workspace_url}/backtests", wait_until="networkidle", timeout=30000)
page.wait_for_url(f"{workspace_url}/backtests", timeout=15000)
expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000) 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 Form").first).to_be_visible(timeout=15000)
expect(page.locator("text=Scenario Summary").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 "RuntimeError" not in rerun_text
assert "Server error" not in rerun_text assert "Server error" not in rerun_text
page.goto(f"{BASE_URL}/event-comparison", wait_until="networkidle", timeout=30000) page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000)
page.wait_for_url(f"{workspace_url}/event-comparison", timeout=15000)
expect(page.locator("text=Event Comparison").first).to_be_visible(timeout=15000) 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=Comparison Form").first).to_be_visible(timeout=15000)
expect(page.locator("text=Ranked Results").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_page.close()
second_context.close() second_context.close()
page.goto(f"{BASE_URL}/hedge", wait_until="domcontentloaded", timeout=30000) page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000)
page.wait_for_url(f"{workspace_url}/hedge", timeout=15000)
expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000) 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 selector").first).to_be_visible(timeout=15000)
expect(page.locator("text=Strategy Controls").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy Controls").first).to_be_visible(timeout=15000)

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
import pytest import pytest
from app.models.portfolio import PortfolioConfig from app.models.portfolio import PortfolioConfig, PortfolioRepository
def test_ltv_calculation(sample_portfolio) -> None: 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) 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: def test_portfolio_config_rejects_invalid_entry_basis_values() -> None:
with pytest.raises(ValueError, match="Entry price must be positive"): with pytest.raises(ValueError, match="Entry price must be positive"):
PortfolioConfig(entry_price=0.0) 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"): 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) 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()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from uuid import uuid4 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 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: def test_root_without_workspace_cookie_shows_welcome_page(tmp_path, monkeypatch) -> None:
_install_workspace_repo(tmp_path, monkeypatch) _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 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: def test_arbitrary_fake_workspace_like_path_redirects_to_welcome_page(tmp_path, monkeypatch) -> None:
_install_workspace_repo(tmp_path, monkeypatch) _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") hedge_response = client.get(f"/{workspace_id}/hedge")
backtests_response = client.get(f"/{workspace_id}/backtests") backtests_response = client.get(f"/{workspace_id}/backtests")
event_response = client.get(f"/{workspace_id}/event-comparison") 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 hedge_response.status_code == 200
assert "Monthly hedge budget" in hedge_response.text 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 "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 "9,680" in event_response.text or "9680" in event_response.text
assert "80.0%" 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"