feat(CORE-001D1): harden unit-aware workspace persistence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user