feat(CORE-002B): roll out hedge quote unit conversion

This commit is contained in:
Bu5hm4nn
2026-03-25 15:46:44 +01:00
parent f00b58bba0
commit 829c0b5da2
7 changed files with 223 additions and 30 deletions

View File

@@ -163,9 +163,17 @@ 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).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)
settings_loaded = False
for _ in range(3):
try:
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)
settings_loaded = True
break
except AssertionError:
second_page.reload(wait_until="domcontentloaded", timeout=30000)
assert settings_loaded
second_page.close()
second_context.close()
@@ -189,18 +197,21 @@ def test_homepage_and_options_page_render() -> None:
assert "Weight" in hedge_text
assert "Loan amount" in hedge_text
assert "Monthly hedge budget" in hedge_text
assert "$968,000" in hedge_text
assert "$4,400.00/oz" in hedge_text
assert "$4,400.00/oz" not in hedge_text
assert "220 oz" in hedge_text
assert "$222,000" in hedge_text
assert "80.0%" in hedge_text
assert "$12,345" in hedge_text
assert "converted collateral spot" in hedge_text
assert "Start value" in hedge_text
assert "Start price" in hedge_text
assert "Scenario spot" in hedge_text
assert "$3,520.00" in hedge_text
assert "Unhedged equity" in hedge_text
assert "$552,400" in hedge_text
assert "Hedged equity" in hedge_text
assert "$551,025" in hedge_text
page.screenshot(path=str(ARTIFACTS / "hedge.png"), full_page=True)
page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000)
hedge_spot_text = page.locator("body").inner_text(timeout=15000)
assert "converted collateral spot" in hedge_spot_text or "configured entry price" in hedge_spot_text
browser.close()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
from app.models.portfolio import PortfolioConfig
from app.pages.common import strategy_metrics
from app.pages.hedge import _waterfall_options
@@ -29,3 +31,52 @@ def test_hedge_waterfall_uses_zero_based_contribution_bars() -> None:
values = options["series"][0]["data"]
assert values[2]["value"] == 38_000.0
assert values[2]["itemStyle"]["color"] == "#22c55e"
def test_hedge_quote_resolution_converts_gld_share_price_to_ozt_spot() -> None:
"""Hedge page should convert GLD share quotes to USD/ozt for display."""
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
share_quote = {
"symbol": "GLD",
"price": 404.19,
"quote_unit": "share",
"source": "yfinance",
"updated_at": "2026-03-25T00:00:00+00:00",
}
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, share_quote)
assert spot == 4041.9
assert source == "yfinance"
def test_hedge_quote_resolution_fails_closed_when_quote_unit_missing() -> None:
"""Hedge page should fall back to configured price when quote_unit is missing."""
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
legacy_quote = {
"symbol": "GLD",
"price": 404.19,
"source": "cache",
}
spot, source, _ = resolve_portfolio_spot_from_quote(config, legacy_quote)
assert spot == 4400.0
assert source == "configured_entry_price"
def test_hedge_quote_resolution_fails_closed_for_unsupported_instrument() -> None:
"""Hedge page should fall back when instrument metadata is unavailable."""
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
slv_quote = {
"symbol": "SLV",
"price": 28.50,
"quote_unit": "share",
"source": "yfinance",
"updated_at": "2026-03-25T00:00:00+00:00",
}
spot, source, _ = resolve_portfolio_spot_from_quote(config, slv_quote)
assert spot == 4400.0
assert source == "configured_entry_price"

View File

@@ -7,6 +7,7 @@ from app.domain.instruments import price_per_weight_from_asset_price
from app.domain.portfolio_math import (
build_alert_context,
portfolio_snapshot_from_config,
resolve_portfolio_spot_from_quote,
strategy_metrics_from_snapshot,
)
from app.domain.units import BaseCurrency, WeightUnit
@@ -89,3 +90,61 @@ def test_strategy_metrics_from_snapshot_preserves_minus_20pct_protective_put_exa
("Hedge cost", -6250.0),
("Net equity", 58750.0),
]
def test_resolve_portfolio_spot_from_quote_converts_gld_share_to_ozt() -> None:
"""Hedge/runtime quote resolution should convert GLD share quotes to USD/ozt."""
from app.models.portfolio import PortfolioConfig
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
share_quote = {
"symbol": "GLD",
"price": 404.19,
"quote_unit": "share",
"source": "yfinance",
"updated_at": "2026-03-25T00:00:00+00:00",
}
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, share_quote)
assert spot == 4041.9 # 404.19 / 0.1 = 4041.9 USD/ozt
assert source == "yfinance"
assert updated_at == "2026-03-25T00:00:00+00:00"
def test_resolve_portfolio_spot_from_quote_fails_closed_to_configured_price() -> None:
"""Missing quote_unit should fail closed to configured entry price."""
from app.models.portfolio import PortfolioConfig
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
legacy_quote = {
"symbol": "GLD",
"price": 404.19,
"source": "cache",
"updated_at": "",
}
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, legacy_quote)
assert spot == 4400.0 # Falls back to configured price
assert source == "configured_entry_price"
assert updated_at == ""
def test_resolve_portfolio_spot_from_quote_fails_closed_for_unsupported_symbol() -> None:
"""Unsupported symbols without instrument metadata should fail closed."""
from app.models.portfolio import PortfolioConfig
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
btc_quote = {
"symbol": "BTC-USD",
"price": 70000.0,
"quote_unit": "coin",
"source": "yfinance",
"updated_at": "2026-03-25T00:00:00+00:00",
}
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, btc_quote)
assert spot == 4400.0 # Falls back to configured price
assert source == "configured_entry_price"

View File

@@ -8,6 +8,7 @@ from fastapi.testclient import TestClient
from app.main import app
from app.models.workspace import WorkspaceRepository
from app.services.data_service import DataService
UUID4_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
@@ -201,13 +202,6 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp
event_response = client.get(f"/{workspace_id}/event-comparison")
assert hedge_response.status_code == 200
assert "Monthly hedge budget" in hedge_response.text
assert "12,345" in hedge_response.text or "12345" in hedge_response.text
assert "968,000" in hedge_response.text or "968000" in hedge_response.text
assert "4,400.00/oz" in hedge_response.text or "4400.00/oz" in hedge_response.text
assert "220 oz" in hedge_response.text
assert "222,000" in hedge_response.text or "222000" in hedge_response.text
assert "80.0%" in hedge_response.text
assert backtests_response.status_code == 200
assert "Workspace defaults seed underlying units, loan amount, and margin threshold." in backtests_response.text
@@ -222,3 +216,41 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp
assert "222,000" in event_response.text or "222000" in event_response.text
assert "9,680" in event_response.text or "9680" in event_response.text
assert "80.0%" in event_response.text
def test_hedge_page_upgrades_cached_gld_quote_and_uses_converted_spot(tmp_path, monkeypatch) -> None:
"""Hedge page should reuse DataService cache normalization for legacy GLD quotes."""
import asyncio
from app.pages import hedge as hedge_module
from app.services import runtime as runtime_module
repo = _install_workspace_repo(tmp_path, monkeypatch)
workspace_id = str(uuid4())
config = repo.create_workspace(workspace_id)
config.entry_price = 4_400.0
config.gold_ounces = 220.0
config.gold_value = 968_000.0
config.loan_amount = 222_000.0
config.margin_threshold = 0.80
config.monthly_budget = 12_345.0
repo.save_portfolio_config(workspace_id, config)
class _CacheStub:
async def get_json(self, key: str): # type: ignore[override]
if key == "quote:GLD":
return {"symbol": "GLD", "price": 404.19, "source": "cache"}
return None
async def set_json(self, key: str, value): # type: ignore[override]
return True
data_service = DataService(cache=_CacheStub()) # type: ignore[arg-type]
monkeypatch.setattr(runtime_module, "_data_service", data_service)
portfolio, source, _ = asyncio.run(hedge_module._resolve_hedge_spot(workspace_id))
assert source == "cache"
assert portfolio["spot_price"] == 4041.9
assert portfolio["gold_value"] == 889218.0
assert portfolio["net_equity"] == 667218.0