feat(CORE-002): add GLD share quote conversion seam
This commit is contained in:
59
tests/test_data_service_quote_units.py
Normal file
59
tests/test_data_service_quote_units.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.cache import CacheService
|
||||
from app.services.data_service import DataService
|
||||
|
||||
|
||||
class _CacheStub(CacheService):
|
||||
def __init__(self, initial: dict[str, object] | None = None) -> None:
|
||||
self._store = dict(initial or {})
|
||||
|
||||
async def get_json(self, key: str): # type: ignore[override]
|
||||
return self._store.get(key)
|
||||
|
||||
async def set_json(self, key: str, value): # type: ignore[override]
|
||||
self._store[key] = value
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_quote_upgrades_cached_gld_quote_with_missing_quote_unit() -> None:
|
||||
cache = _CacheStub(
|
||||
{
|
||||
"quote:GLD": {
|
||||
"symbol": "GLD",
|
||||
"price": 404.19,
|
||||
"change": 0.0,
|
||||
"change_percent": 0.0,
|
||||
"updated_at": "2026-03-25T00:00:00+00:00",
|
||||
"source": "cache",
|
||||
}
|
||||
}
|
||||
)
|
||||
service = DataService(cache=cache)
|
||||
|
||||
quote = await service.get_quote("GLD")
|
||||
|
||||
assert quote["quote_unit"] == "share"
|
||||
assert cache._store["quote:GLD"]["quote_unit"] == "share"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_quote_preserves_missing_quote_unit_for_unsupported_symbols() -> None:
|
||||
cache = _CacheStub(
|
||||
{
|
||||
"quote:BTC-USD": {
|
||||
"symbol": "BTC-USD",
|
||||
"price": 70000.0,
|
||||
"updated_at": "2026-03-25T00:00:00+00:00",
|
||||
"source": "cache",
|
||||
}
|
||||
}
|
||||
)
|
||||
service = DataService(cache=cache, default_symbol="BTC-USD")
|
||||
|
||||
quote = await service.get_quote("BTC-USD")
|
||||
|
||||
assert "quote_unit" not in quote
|
||||
@@ -141,10 +141,13 @@ def test_homepage_and_options_page_render() -> None:
|
||||
assert "Options Chain" in overview_text
|
||||
assert "Backtests" in overview_text
|
||||
assert "Event Comparison" in overview_text
|
||||
assert "Live quote source: configured entry price fallback" in overview_text
|
||||
assert "Live quote source:" in overview_text
|
||||
assert "GLD share quote converted to ozt-equivalent spot" in overview_text
|
||||
assert "configured entry price fallback" not in overview_text
|
||||
assert "Collateral Spot Price" in overview_text
|
||||
assert "$1,261.36" in overview_text
|
||||
assert "$968,000.00" in overview_text
|
||||
assert "$746,000.00" in overview_text
|
||||
assert "$968,000" in overview_text
|
||||
assert "$222,000" in overview_text
|
||||
expect(page.get_by_role("link", name="Hedge Analysis")).to_have_attribute("href", f"/{workspace_id}/hedge")
|
||||
expect(page.get_by_role("link", name="Backtests")).to_have_attribute("href", f"/{workspace_id}/backtests")
|
||||
expect(page.get_by_role("link", name="Event Comparison")).to_have_attribute(
|
||||
@@ -160,7 +163,9 @@ def test_homepage_and_options_page_render() -> None:
|
||||
second_workspace_url = second_page.url
|
||||
assert second_workspace_url != workspace_url
|
||||
second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)
|
||||
expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000")
|
||||
expect(second_page).to_have_url(f"{second_workspace_url}/settings")
|
||||
expect(second_page.locator("text=Settings").first).to_be_visible(timeout=15000)
|
||||
expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000", timeout=15000)
|
||||
second_page.close()
|
||||
second_context.close()
|
||||
|
||||
|
||||
52
tests/test_instruments.py
Normal file
52
tests/test_instruments.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.backtesting_math import AssetQuantity, PricePerAsset
|
||||
from app.domain.instruments import (
|
||||
asset_quantity_from_weight,
|
||||
price_per_weight_from_asset_price,
|
||||
weight_from_asset_quantity,
|
||||
)
|
||||
from app.domain.units import BaseCurrency, Weight, WeightUnit
|
||||
|
||||
|
||||
def test_gld_share_quantity_converts_to_troy_ounce_weight() -> None:
|
||||
quantity = AssetQuantity(amount=Decimal("10"), symbol="GLD")
|
||||
|
||||
weight = weight_from_asset_quantity(quantity)
|
||||
|
||||
assert weight == Weight(amount=Decimal("1.0"), unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
|
||||
def test_gld_troy_ounce_weight_converts_to_share_quantity() -> None:
|
||||
weight = Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
quantity = asset_quantity_from_weight("GLD", weight)
|
||||
|
||||
assert quantity == AssetQuantity(amount=Decimal("10"), symbol="GLD")
|
||||
|
||||
|
||||
def test_gld_share_quote_converts_to_ounce_equivalent_spot() -> None:
|
||||
quote = PricePerAsset(amount=Decimal("404.19"), currency=BaseCurrency.USD, symbol="GLD")
|
||||
|
||||
spot = price_per_weight_from_asset_price(quote, per_unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
assert spot.amount == Decimal("4041.9")
|
||||
assert spot.currency is BaseCurrency.USD
|
||||
assert spot.per_unit is WeightUnit.OUNCE_TROY
|
||||
|
||||
|
||||
def test_instrument_conversions_fail_closed_for_unsupported_symbols() -> None:
|
||||
quote = PricePerAsset(amount=Decimal("28.50"), currency=BaseCurrency.USD, symbol="SLV")
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported instrument metadata"):
|
||||
price_per_weight_from_asset_price(quote, per_unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported instrument metadata"):
|
||||
weight_from_asset_quantity(AssetQuantity(amount=Decimal("10"), symbol="SLV"))
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported instrument metadata"):
|
||||
asset_quantity_from_weight("SLV", Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY))
|
||||
@@ -1,16 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||
from app.models.portfolio import PortfolioConfig
|
||||
from app.pages.overview import _resolve_overview_spot
|
||||
from app.services.alerts import build_portfolio_alert_context
|
||||
|
||||
|
||||
def test_overview_falls_back_to_configured_entry_price_when_live_quote_units_do_not_match() -> None:
|
||||
def test_overview_converts_gld_share_quote_to_ounce_equivalent_spot() -> None:
|
||||
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
||||
|
||||
spot_price, source, updated_at = _resolve_overview_spot(
|
||||
spot_price, source, updated_at = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{"price": 404.19, "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"},
|
||||
{
|
||||
"symbol": "GLD",
|
||||
"price": 404.19,
|
||||
"quote_unit": "share",
|
||||
"source": "yfinance",
|
||||
"updated_at": "2026-03-24T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
portfolio = build_portfolio_alert_context(
|
||||
config,
|
||||
@@ -19,8 +25,90 @@ def test_overview_falls_back_to_configured_entry_price_when_live_quote_units_do_
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
assert spot_price == 4041.9
|
||||
assert source == "yfinance"
|
||||
assert updated_at == "2026-03-24T00:00:00+00:00"
|
||||
assert portfolio["gold_value"] == 889218.0
|
||||
assert portfolio["net_equity"] == 744218.0
|
||||
assert round(float(portfolio["margin_call_price"]), 2) == 878.79
|
||||
|
||||
|
||||
def test_overview_fails_closed_to_configured_entry_price_for_unsupported_quote_symbol() -> None:
|
||||
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
||||
|
||||
spot_price, source, updated_at = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{
|
||||
"symbol": "SLV",
|
||||
"price": 28.50,
|
||||
"quote_unit": "share",
|
||||
"source": "yfinance",
|
||||
"updated_at": "2026-03-24T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert spot_price == 4400.0
|
||||
assert source == "configured_entry_price"
|
||||
assert portfolio["gold_value"] == 968000.0
|
||||
assert portfolio["net_equity"] == 823000.0
|
||||
assert round(float(portfolio["margin_call_price"]), 2) == 878.79
|
||||
assert updated_at == ""
|
||||
|
||||
|
||||
def test_overview_uses_fallback_symbol_when_quote_payload_omits_symbol() -> None:
|
||||
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
||||
|
||||
spot_price, source, updated_at = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{"price": 404.19, "quote_unit": "share", "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"},
|
||||
fallback_symbol="GLD",
|
||||
)
|
||||
|
||||
assert spot_price == 4041.9
|
||||
assert source == "yfinance"
|
||||
assert updated_at == "2026-03-24T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_overview_falls_back_when_quote_price_is_missing_or_invalid() -> None:
|
||||
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
||||
|
||||
missing_price = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{"symbol": "GLD", "quote_unit": "share", "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"},
|
||||
fallback_symbol="GLD",
|
||||
)
|
||||
zero_price = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{
|
||||
"symbol": "GLD",
|
||||
"price": 0.0,
|
||||
"quote_unit": "share",
|
||||
"source": "yfinance",
|
||||
"updated_at": "2026-03-24T00:00:00+00:00",
|
||||
},
|
||||
fallback_symbol="GLD",
|
||||
)
|
||||
bad_string_price = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{
|
||||
"symbol": "GLD",
|
||||
"price": "not-a-number",
|
||||
"quote_unit": "share",
|
||||
"source": "yfinance",
|
||||
"updated_at": "2026-03-24T00:00:00+00:00",
|
||||
},
|
||||
fallback_symbol="GLD",
|
||||
)
|
||||
|
||||
assert missing_price == (4400.0, "configured_entry_price", "")
|
||||
assert zero_price == (4400.0, "configured_entry_price", "")
|
||||
assert bad_string_price == (4400.0, "configured_entry_price", "")
|
||||
|
||||
|
||||
def test_overview_falls_back_when_quote_unit_metadata_is_missing() -> None:
|
||||
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
||||
|
||||
result = resolve_portfolio_spot_from_quote(
|
||||
config,
|
||||
{"symbol": "GLD", "price": 404.19, "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"},
|
||||
fallback_symbol="GLD",
|
||||
)
|
||||
|
||||
assert result == (4400.0, "configured_entry_price", "")
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.backtesting_math import PricePerAsset
|
||||
from app.domain.instruments import price_per_weight_from_asset_price
|
||||
from app.domain.portfolio_math import (
|
||||
build_alert_context,
|
||||
portfolio_snapshot_from_config,
|
||||
strategy_metrics_from_snapshot,
|
||||
)
|
||||
from app.domain.units import BaseCurrency, WeightUnit
|
||||
from app.models.portfolio import PortfolioConfig
|
||||
from app.pages.common import strategy_catalog
|
||||
|
||||
|
||||
def test_portfolio_snapshot_from_config_preserves_weight_price_and_margin_values() -> None:
|
||||
@@ -37,7 +41,27 @@ def test_build_alert_context_uses_unit_safe_gold_value_calculation() -> None:
|
||||
assert round(float(context["margin_call_price"]), 2) == 878.79
|
||||
|
||||
|
||||
def test_build_alert_context_accepts_explicit_gld_share_quote_conversion() -> None:
|
||||
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
||||
share_quote = PricePerAsset(amount=Decimal("404.19"), currency=BaseCurrency.USD, symbol="GLD")
|
||||
ounce_spot = price_per_weight_from_asset_price(share_quote, per_unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
context = build_alert_context(
|
||||
config,
|
||||
spot_price=float(ounce_spot.amount),
|
||||
source="yfinance",
|
||||
updated_at="2026-03-24T00:00:00+00:00",
|
||||
)
|
||||
|
||||
assert context["spot_price"] == 4041.9
|
||||
assert context["gold_value"] == 889218.0
|
||||
assert context["net_equity"] == 744218.0
|
||||
assert context["quote_source"] == "yfinance"
|
||||
|
||||
|
||||
def test_strategy_metrics_from_snapshot_preserves_minus_20pct_protective_put_example() -> None:
|
||||
from app.pages.common import strategy_catalog
|
||||
|
||||
strategy = next(item for item in strategy_catalog() if item["name"] == "protective_put_atm")
|
||||
snapshot = {
|
||||
"gold_value": 215000.0,
|
||||
|
||||
Reference in New Issue
Block a user