diff --git a/app/models/portfolio.py b/app/models/portfolio.py index c35a1c2..2751176 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -66,14 +66,20 @@ class PortfolioConfig: """User portfolio configuration with validation. Attributes: - gold_value: Current gold collateral value in USD + gold_value: Collateral baseline value in USD at entry + entry_price: Gold entry price per ounce in USD + gold_ounces: Canonical gold collateral weight in ounces + entry_basis_mode: Preferred settings UI input mode loan_amount: Outstanding loan amount in USD margin_threshold: LTV threshold for margin call (default 0.75) monthly_budget: Approved monthly hedge budget ltv_warning: LTV warning level for alerts (default 0.70) """ - gold_value: float = 215000.0 + gold_value: float | None = None + entry_price: float | None = 215.0 + gold_ounces: float | None = None + entry_basis_mode: str = "value_price" loan_amount: float = 145000.0 margin_threshold: float = 0.75 monthly_budget: float = 8000.0 @@ -89,14 +95,57 @@ class PortfolioConfig: spot_drawdown: float = 7.5 email_alerts: bool = False - def __post_init__(self): - """Validate configuration after initialization.""" + def __post_init__(self) -> None: + """Normalize entry basis fields and validate configuration.""" + self._normalize_entry_basis() self.validate() + def _normalize_entry_basis(self) -> None: + """Resolve user input into canonical weight + entry price representation.""" + if self.entry_basis_mode not in {"value_price", "weight"}: + raise ValueError("Entry basis mode must be 'value_price' or 'weight'") + + if self.entry_price is None or self.entry_price <= 0: + raise ValueError("Entry price must be positive") + + if self.gold_value is not None and self.gold_value <= 0: + raise ValueError("Gold value must be positive") + if self.gold_ounces is not None and self.gold_ounces <= 0: + 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_ounces = self.gold_value / self.entry_price + return + + if self.gold_value is None and self.gold_ounces is not None: + self.gold_value = self.gold_ounces * self.entry_price + return + + if self.gold_ounces is None and self.gold_value is not None: + self.gold_ounces = self.gold_value / self.entry_price + return + + assert self.gold_value is not None + assert self.gold_ounces is not None + derived_gold_value = self.gold_ounces * self.entry_price + tolerance = max(0.01, abs(derived_gold_value) * 1e-9) + if abs(self.gold_value - derived_gold_value) > tolerance: + raise ValueError("Gold value and weight contradict each other") + self.gold_value = derived_gold_value + def validate(self) -> None: """Validate configuration values.""" + assert self.gold_value is not None + assert self.entry_price is not None + assert self.gold_ounces is not None + if self.gold_value <= 0: raise ValueError("Gold value must be positive") + if self.entry_price <= 0: + raise ValueError("Entry price must be positive") + if self.gold_ounces <= 0: + raise ValueError("Gold weight must be positive") if self.loan_amount < 0: raise ValueError("Loan amount cannot be negative") if self.loan_amount >= self.gold_value: @@ -111,6 +160,7 @@ class PortfolioConfig: @property def current_ltv(self) -> float: """Calculate current loan-to-value ratio.""" + assert self.gold_value is not None if self.gold_value == 0: return 0.0 return self.loan_amount / self.gold_value @@ -123,19 +173,27 @@ class PortfolioConfig: @property def net_equity(self) -> float: """Calculate net equity (gold value - loan).""" + assert self.gold_value is not None return self.gold_value - self.loan_amount @property def margin_call_price(self) -> float: - """Calculate gold price at which margin call occurs.""" - if self.margin_threshold == 0: + """Calculate gold price per ounce at which margin call occurs.""" + assert self.gold_ounces is not None + if self.margin_threshold == 0 or self.gold_ounces == 0: return float("inf") - return self.loan_amount / self.margin_threshold + return self.loan_amount / (self.margin_threshold * self.gold_ounces) def to_dict(self) -> dict[str, Any]: """Convert configuration to dictionary.""" + assert self.gold_value is not None + assert self.entry_price is not None + assert self.gold_ounces is not None return { "gold_value": self.gold_value, + "entry_price": self.entry_price, + "gold_ounces": self.gold_ounces, + "entry_basis_mode": self.entry_basis_mode, "loan_amount": self.loan_amount, "margin_threshold": self.margin_threshold, "monthly_budget": self.monthly_budget, @@ -162,8 +220,7 @@ class PortfolioRepository: CONFIG_PATH = Path("data/portfolio_config.json") - def __init__(self): - # Ensure data directory exists + def __init__(self) -> None: self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) def save(self, config: PortfolioConfig) -> None: @@ -190,7 +247,6 @@ class PortfolioRepository: return PortfolioConfig() -# Singleton repository instance _portfolio_repo: PortfolioRepository | None = None diff --git a/app/pages/overview.py b/app/pages/overview.py index 1398985..ca07698 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -9,7 +9,6 @@ from app.models.portfolio import PortfolioConfig, get_portfolio_repository from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog from app.services.runtime import get_data_service -_REFERENCE_SPOT_PRICE = 215.0 _DEFAULT_CASH_BUFFER = 18_500.0 @@ -24,23 +23,24 @@ def _format_timestamp(value: str | None) -> str: def _build_live_portfolio(config: PortfolioConfig, quote: dict[str, object]) -> dict[str, float | str]: - spot_price = float(quote.get("price", _REFERENCE_SPOT_PRICE)) - configured_gold_value = float(config.gold_value) - estimated_units = configured_gold_value / _REFERENCE_SPOT_PRICE if _REFERENCE_SPOT_PRICE > 0 else 0.0 - live_gold_value = estimated_units * spot_price + fallback_spot_price = float(config.entry_price or 0.0) + spot_price = float(quote.get("price", fallback_spot_price)) + configured_gold_value = float(config.gold_value or 0.0) + gold_units = float(config.gold_ounces or 0.0) + live_gold_value = gold_units * spot_price loan_amount = float(config.loan_amount) margin_call_ltv = float(config.margin_threshold) ltv_ratio = loan_amount / live_gold_value if live_gold_value > 0 else 0.0 return { "spot_price": spot_price, - "gold_units": estimated_units, + "gold_units": gold_units, "gold_value": live_gold_value, "loan_amount": loan_amount, "ltv_ratio": ltv_ratio, "net_equity": live_gold_value - loan_amount, "margin_call_ltv": margin_call_ltv, - "margin_call_price": loan_amount / (margin_call_ltv * estimated_units) if estimated_units > 0 else 0.0, + "margin_call_price": loan_amount / (margin_call_ltv * gold_units) if gold_units > 0 else 0.0, "cash_buffer": max(live_gold_value - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER, "hedge_budget": float(config.monthly_budget), "quote_source": str(quote.get("source", "unknown")), diff --git a/app/pages/settings.py b/app/pages/settings.py index 6dec475..f9f7f69 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -2,20 +2,35 @@ from __future__ import annotations from nicegui import ui -from app.pages.common import dashboard_page from app.models.portfolio import PortfolioConfig, get_portfolio_repository +from app.pages.common import dashboard_page @ui.page("/settings") -def settings_page(): +def settings_page() -> None: """Settings page with persistent portfolio configuration.""" - # Load current configuration repo = get_portfolio_repository() config = repo.load() - + + syncing_entry_basis = False + + def as_positive_float(value: object) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + def as_non_negative_float(value: object) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return 0.0 + return max(parsed, 0.0) + with dashboard_page( "Settings", - "Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.", + "Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.", "settings", ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): @@ -23,46 +38,70 @@ def settings_page(): "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Portfolio Parameters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - - gold_value = ui.number( - "Gold collateral value ($)", - value=config.gold_value, - min=0.01, # Must be positive - step=1000 - ).classes("w-full") - - loan_amount = ui.number( - "Loan amount ($)", - value=config.loan_amount, - min=0, - step=1000 - ).classes("w-full") - - margin_threshold = ui.number( - "Margin call LTV threshold", - value=config.margin_threshold, - min=0.1, - max=0.95, - step=0.01 - ).classes("w-full") - - monthly_budget = ui.number( - "Monthly hedge budget ($)", - value=config.monthly_budget, - min=0, - step=500 + ui.label( + "Choose whether collateral entry is keyed by start value or by gold weight. The paired field is derived automatically from the entry price." + ).classes("text-sm text-slate-500 dark:text-slate-400") + + entry_basis_mode = ui.select( + {"value_price": "Start value + entry price", "weight": "Gold weight + entry price"}, + value=config.entry_basis_mode, + label="Collateral entry basis", ).classes("w-full") - # Show calculated values - with ui.row().classes("w-full gap-2 mt-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg"): + entry_price = ui.number( + "Entry price ($/oz)", + value=config.entry_price, + min=0.01, + step=0.01, + ).classes("w-full") + + gold_value = ui.number( + "Collateral start value ($)", + value=config.gold_value, + min=0.01, + step=1000, + ).classes("w-full") + + gold_ounces = ui.number( + "Gold weight (oz)", + value=config.gold_ounces, + min=0.0001, + step=0.01, + ).classes("w-full") + + loan_amount = ui.number( + "Loan amount ($)", + value=config.loan_amount, + min=0, + step=1000, + ).classes("w-full") + + margin_threshold = ui.number( + "Margin call LTV threshold", + value=config.margin_threshold, + min=0.1, + max=0.95, + step=0.01, + ).classes("w-full") + + monthly_budget = ui.number( + "Monthly hedge budget ($)", + value=config.monthly_budget, + min=0, + step=500, + ).classes("w-full") + + derived_hint = ui.label().classes("text-sm text-slate-500 dark:text-slate-400") + + with ui.row().classes("w-full gap-2 mt-4 rounded-lg bg-slate-50 p-4 dark:bg-slate-800"): ui.label("Current LTV:").classes("font-medium") - ltv_display = ui.label(f"{(config.loan_amount / config.gold_value * 100):.1f}%") - - ui.label("Margin buffer:").classes("font-medium ml-4") - buffer_display = ui.label(f"{((config.margin_threshold - config.loan_amount / config.gold_value) * 100):.1f}%") - - ui.label("Margin call at:").classes("font-medium ml-4") - margin_price_display = ui.label(f"${(config.loan_amount / config.margin_threshold):,.2f}") + ltv_display = ui.label() + + ui.label("Margin buffer:").classes("ml-4 font-medium") + buffer_display = ui.label() + + ui.label("Margin call at:").classes("ml-4 font-medium") + margin_price_display = ui.label() with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" @@ -79,33 +118,11 @@ def settings_page(): label="Fallback source", ).classes("w-full") refresh_interval = ui.number( - "Refresh interval (seconds)", - value=config.refresh_interval, - min=1, - step=1 + "Refresh interval (seconds)", + value=config.refresh_interval, + min=1, + step=1, ).classes("w-full") - - def update_calculations(): - """Update calculated displays when values change.""" - try: - gold = gold_value.value or 1 # Avoid division by zero - loan = loan_amount.value or 0 - margin = margin_threshold.value or 0.75 - - ltv = (loan / gold) * 100 - buffer = (margin - loan / gold) * 100 - margin_price = loan / margin if margin > 0 else 0 - - ltv_display.set_text(f"{ltv:.1f}%") - buffer_display.set_text(f"{buffer:.1f}%") - margin_price_display.set_text(f"${margin_price:,.2f}") - except Exception: - pass # Ignore calculation errors during editing - - # Connect update function to value changes - gold_value.on_value_change(update_calculations) - loan_amount.on_value_change(update_calculations) - margin_threshold.on_value_change(update_calculations) with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( @@ -113,52 +130,116 @@ def settings_page(): ): ui.label("Alert Thresholds").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ltv_warning = ui.number( - "LTV warning level", - value=config.ltv_warning, - min=0.1, - max=0.95, - step=0.01 + "LTV warning level", + value=config.ltv_warning, + min=0.1, + max=0.95, + step=0.01, ).classes("w-full") vol_alert = ui.number( - "Volatility spike alert", - value=config.volatility_spike, - min=0.01, - max=2.0, - step=0.01 + "Volatility spike alert", + value=config.volatility_spike, + min=0.01, + max=2.0, + step=0.01, ).classes("w-full") price_alert = ui.number( - "Spot drawdown alert (%)", - value=config.spot_drawdown, - min=0.1, - max=50.0, - step=0.5 + "Spot drawdown alert (%)", + value=config.spot_drawdown, + min=0.1, + max=50.0, + step=0.5, ).classes("w-full") - email_alerts = ui.switch( - "Email alerts", - value=config.email_alerts - ) + email_alerts = ui.switch("Email alerts", value=config.email_alerts) with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Export / Import").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - export_format = ui.select( - ["json", "csv", "yaml"], - value="json", - label="Export format" - ).classes("w-full") + ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full") ui.switch("Include scenario history", value=True) ui.switch("Include option selections", value=True) ui.button("Import settings", icon="upload").props("outline color=primary") ui.button("Export settings", icon="download").props("outline color=primary") - def save_settings(): - """Save settings with validation and persistence.""" + def apply_entry_basis_mode() -> None: + mode = str(entry_basis_mode.value or "value_price") + if mode == "weight": + gold_value.props("readonly") + gold_ounces.props(remove="readonly") + derived_hint.set_text("Gold weight is the editable basis; start value is derived from weight × entry price.") + else: + gold_ounces.props("readonly") + gold_value.props(remove="readonly") + derived_hint.set_text("Start value is the editable basis; gold weight is derived from start value ÷ entry price.") + + def update_entry_basis(*_args: object) -> None: + nonlocal syncing_entry_basis + apply_entry_basis_mode() + if syncing_entry_basis: + return + + price = as_positive_float(entry_price.value) + if price is None: + update_calculations() + return + + syncing_entry_basis = True + try: + mode = str(entry_basis_mode.value or "value_price") + if mode == "weight": + ounces = as_positive_float(gold_ounces.value) + if ounces is not None: + gold_value.value = round(ounces * price, 2) + else: + start_value = as_positive_float(gold_value.value) + if start_value is not None: + gold_ounces.value = round(start_value / price, 6) + finally: + syncing_entry_basis = False + + update_calculations() + + def update_calculations(*_args: object) -> None: + price = as_positive_float(entry_price.value) + collateral_value = as_positive_float(gold_value.value) + ounces = as_positive_float(gold_ounces.value) + loan = as_non_negative_float(loan_amount.value) + margin = as_positive_float(margin_threshold.value) + + if collateral_value is not None and collateral_value > 0: + ltv = (loan / collateral_value) * 100 + buffer = ((margin or 0.0) - loan / collateral_value) * 100 if margin is not None else 0.0 + ltv_display.set_text(f"{ltv:.1f}%") + buffer_display.set_text(f"{buffer:.1f}%") + else: + ltv_display.set_text("—") + buffer_display.set_text("—") + + if margin is not None and ounces is not None and ounces > 0: + margin_price_display.set_text(f"${loan / (margin * ounces):,.2f}/oz") + elif margin is not None and price is not None and collateral_value is not None and collateral_value > 0: + implied_ounces = collateral_value / price + margin_price_display.set_text(f"${loan / (margin * implied_ounces):,.2f}/oz") + else: + margin_price_display.set_text("—") + + for element in (entry_basis_mode, entry_price, gold_value, gold_ounces): + element.on_value_change(update_entry_basis) + for element in (loan_amount, margin_threshold): + element.on_value_change(update_calculations) + + apply_entry_basis_mode() + update_entry_basis() + + def save_settings() -> None: try: - # Create new config from form values new_config = PortfolioConfig( - gold_value=float(gold_value.value), - loan_amount=float(loan_amount.value), + gold_value=as_positive_float(gold_value.value), + entry_price=as_positive_float(entry_price.value), + gold_ounces=as_positive_float(gold_ounces.value), + entry_basis_mode=str(entry_basis_mode.value), + loan_amount=as_non_negative_float(loan_amount.value), margin_threshold=float(margin_threshold.value), monthly_budget=float(monthly_budget.value), ltv_warning=float(ltv_warning.value), @@ -169,27 +250,23 @@ def settings_page(): spot_drawdown=float(price_alert.value), email_alerts=bool(email_alerts.value), ) - - # Save to repository + repo.save(new_config) - + status.set_text( - f"Saved: gold=${new_config.gold_value:,.0f}, " - f"loan=${new_config.loan_amount:,.0f}, " - f"LTV={new_config.current_ltv:.1%}, " - f"margin={new_config.margin_threshold:.1%}, " - f"buffer={new_config.margin_buffer:.1%}" + f"Saved: basis={new_config.entry_basis_mode}, start=${new_config.gold_value:,.0f}, " + f"entry=${new_config.entry_price:,.2f}/oz, weight={new_config.gold_ounces:,.2f} oz, " + f"LTV={new_config.current_ltv:.1%}, trigger=${new_config.margin_call_price:,.2f}/oz" ) ui.notify("Settings saved successfully", color="positive") - except ValueError as e: ui.notify(f"Validation error: {e}", color="negative") except Exception as e: ui.notify(f"Failed to save: {e}", color="negative") - with ui.row().classes("w-full items-center justify-between gap-4 mt-6"): + with ui.row().classes("mt-6 w-full items-center justify-between gap-4"): status = ui.label( - f"Current: gold=${config.gold_value:,.0f}, loan=${config.loan_amount:,.0f}, " - f"current LTV={config.current_ltv:.1%}" + f"Current: start=${config.gold_value:,.0f}, entry=${config.entry_price:,.2f}/oz, " + f"weight={config.gold_ounces:,.2f} oz, current LTV={config.current_ltv:.1%}" ).classes("text-sm text-slate-500 dark:text-slate-400") ui.button("Save settings", on_click=save_settings).props("color=primary") diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3e24b1..58c6d42 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -272,13 +272,30 @@ DATA-001 (Price Feed) **Dependencies:** stable deployed app on VPS +### PORT-001A: Collateral Entry Basis in Settings [P1, M] **[depends: PORT-001]** +**As a** portfolio manager, **I want** to store my collateral entry basis **so that** the dashboard can derive position size consistently from either cost basis or weight. + +**Acceptance Criteria:** +- Settings supports entering either: + - total collateral start value **and** entry price, or + - gold weight directly +- Updating one mode calculates the other representation automatically +- Persist both the chosen input mode and the derived values needed by the app +- Validation prevents impossible or contradictory values (zero/negative price, value, or weight) +- UI makes the relationship explicit: `weight = start_value / entry_price` +- Browser-visible test covers switching modes and saving derived values + +**Technical Notes:** +- Extend `PortfolioConfig` / settings persistence with entry-basis fields +- Keep overview and other consumers compatible with existing portfolio settings +- Prefer a single canonical stored representation plus derived display fields to avoid drift + +**Dependencies:** PORT-001 + ## Implementation Priority Queue -1. **DATA-002A** - Fix options UX/performance regression -2. **DATA-001A** - Remove misleading mock overview price -3. **OPS-001** - Add Caddy route once app behavior is stable -4. **DATA-003** - Risk metrics -5. **PORT-002** - Risk management safety -6. **EXEC-001** - Core user workflow -7. **EXEC-002** - Execution capability -8. Remaining features +1. **PORT-001A** - Add collateral entry basis and derived weight/value handling in settings +2. **PORT-002** - Risk management safety +3. **EXEC-001** - Core user workflow +4. **EXEC-002** - Execution capability +5. Remaining features diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 6ccbd4f..288a44b 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -29,4 +29,13 @@ def test_homepage_and_options_page_render() -> None: assert "RuntimeError" not in body_text page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True) + page.goto(f"{BASE_URL}/settings", wait_until="domcontentloaded", timeout=30000) + expect(page.locator("text=Settings").first).to_be_visible(timeout=15000) + expect(page.locator("text=Collateral entry basis").first).to_be_visible(timeout=15000) + expect(page.locator("text=Entry price ($/oz)").first).to_be_visible(timeout=15000) + settings_text = page.locator("body").inner_text(timeout=15000) + assert "RuntimeError" not in settings_text + assert "Server error" not in settings_text + page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True) + browser.close() diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index a70af0f..af76ded 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -2,6 +2,8 @@ from __future__ import annotations import pytest +from app.models.portfolio import PortfolioConfig + def test_ltv_calculation(sample_portfolio) -> None: assert sample_portfolio.current_ltv == pytest.approx(0.60, rel=1e-12) @@ -20,3 +22,57 @@ def test_net_equity_calculation(sample_portfolio) -> None: def test_margin_call_threshold(sample_portfolio) -> None: assert sample_portfolio.margin_call_price() == pytest.approx(368.0, rel=1e-12) assert sample_portfolio.ltv_at_price(sample_portfolio.margin_call_price()) == pytest.approx(0.75, rel=1e-12) + + +def test_portfolio_config_derives_gold_weight_from_start_value_and_entry_price() -> None: + config = PortfolioConfig( + gold_value=215_000.0, + entry_price=215.0, + entry_basis_mode="value_price", + ) + + assert config.gold_ounces == pytest.approx(1_000.0, rel=1e-12) + assert config.gold_value == pytest.approx(215_000.0, rel=1e-12) + assert config.entry_basis_mode == "value_price" + + +def test_portfolio_config_derives_start_value_from_gold_weight_and_entry_price() -> None: + config = PortfolioConfig( + gold_ounces=1_000.0, + entry_price=215.0, + entry_basis_mode="weight", + ) + + assert config.gold_value == pytest.approx(215_000.0, rel=1e-12) + assert config.gold_ounces == pytest.approx(1_000.0, rel=1e-12) + assert config.entry_basis_mode == "weight" + + +def test_portfolio_config_serializes_canonical_entry_basis_fields() -> None: + config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0) + + data = config.to_dict() + + assert data["gold_value"] == pytest.approx(215_000.0, rel=1e-12) + assert data["gold_ounces"] == pytest.approx(1_000.0, rel=1e-12) + assert data["entry_price"] == pytest.approx(215.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: + with pytest.raises(ValueError, match="Entry price must be positive"): + PortfolioConfig(entry_price=0.0) + + with pytest.raises(ValueError, match="Gold weight must be positive"): + PortfolioConfig(gold_ounces=-1.0, entry_price=215.0, entry_basis_mode="weight") + + 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)