Files
vault-dash/tests/test_portfolio.py

231 lines
9.0 KiB
Python

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_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()