from __future__ import annotations import json import pytest from app.models.portfolio import PortfolioConfig, PortfolioRepository def test_ltv_calculation(sample_portfolio) -> None: assert sample_portfolio.current_ltv == pytest.approx(0.60, rel=1e-12) assert sample_portfolio.ltv_at_price(460.0) == pytest.approx(0.60, rel=1e-12) assert sample_portfolio.ltv_at_price(368.0) == pytest.approx(0.75, rel=1e-12) def test_net_equity_calculation(sample_portfolio) -> None: assert sample_portfolio.net_equity == pytest.approx(400_000.0, rel=1e-12) assert sample_portfolio.net_equity_at_price(420.0) == pytest.approx( sample_portfolio.gold_ounces * 420.0 - 600_000.0, rel=1e-12, ) 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_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) 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_config_defaults_to_100_ounces() -> None: config = PortfolioConfig() assert config.gold_value == pytest.approx(215_000.0, rel=1e-12) assert config.entry_price == pytest.approx(2_150.0, rel=1e-12) assert config.gold_ounces == pytest.approx(100.0, rel=1e-12) def test_portfolio_repository_upgrades_legacy_default_workspace_footprint(tmp_path) -> None: config_path = tmp_path / "portfolio_config.json" config_path.write_text( json.dumps( { "schema_version": 2, "portfolio": { "gold_value": {"value": "215000.0", "currency": "USD"}, "entry_price": {"value": "215.0", "currency": "USD", "per_weight_unit": "ozt"}, "gold_ounces": {"value": "1000.0", "unit": "ozt"}, "entry_basis_mode": "value_price", "loan_amount": {"value": "145000.0", "currency": "USD"}, "margin_threshold": {"value": "0.75", "unit": "ratio"}, "monthly_budget": {"value": "8000.0", "currency": "USD"}, "ltv_warning": {"value": "0.70", "unit": "ratio"}, "primary_source": "yfinance", "fallback_source": "yfinance", "refresh_interval": {"value": 5, "unit": "seconds"}, "volatility_spike": {"value": "0.25", "unit": "ratio"}, "spot_drawdown": {"value": "7.5", "unit": "percent"}, "email_alerts": False, }, } ) ) config = PortfolioRepository(config_path=config_path).load() assert config.gold_value == pytest.approx(215_000.0, rel=1e-12) assert config.entry_price == pytest.approx(2_150.0, rel=1e-12) assert config.gold_ounces == pytest.approx(100.0, rel=1e-12) def test_portfolio_repository_rejects_non_integer_refresh_interval_value(tmp_path) -> None: repo = PortfolioRepository(config_path=tmp_path / "portfolio_config.json") config = PortfolioConfig() 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()