231 lines
9.0 KiB
Python
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()
|