feat(CORE-002B): roll out hedge quote unit conversion
This commit is contained in:
@@ -82,7 +82,11 @@ def resolve_portfolio_spot_from_quote(
|
|||||||
return _decimal_to_float(converted_spot.amount), quote_source, quote_updated_at
|
return _decimal_to_float(converted_spot.amount), quote_source, quote_updated_at
|
||||||
|
|
||||||
|
|
||||||
def portfolio_snapshot_from_config(config: PortfolioConfig | None = None) -> dict[str, float]:
|
def portfolio_snapshot_from_config(
|
||||||
|
config: PortfolioConfig | None = None,
|
||||||
|
*,
|
||||||
|
runtime_spot_price: float | None = None,
|
||||||
|
) -> dict[str, float]:
|
||||||
if config is None:
|
if config is None:
|
||||||
gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY)
|
gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY)
|
||||||
spot = PricePerWeight(amount=Decimal("215"), currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY)
|
spot = PricePerWeight(amount=Decimal("215"), currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY)
|
||||||
@@ -91,7 +95,8 @@ def portfolio_snapshot_from_config(config: PortfolioConfig | None = None) -> dic
|
|||||||
hedge_budget = Money(amount=Decimal("8000"), currency=BaseCurrency.USD)
|
hedge_budget = Money(amount=Decimal("8000"), currency=BaseCurrency.USD)
|
||||||
else:
|
else:
|
||||||
gold_weight = _gold_weight(float(config.gold_ounces or 0.0))
|
gold_weight = _gold_weight(float(config.gold_ounces or 0.0))
|
||||||
spot = _spot_price(float(config.entry_price or 0.0))
|
resolved_spot = runtime_spot_price if runtime_spot_price is not None else float(config.entry_price or 0.0)
|
||||||
|
spot = _spot_price(resolved_spot)
|
||||||
loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD)
|
loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD)
|
||||||
margin_call_ltv = decimal_from_float(float(config.margin_threshold))
|
margin_call_ltv = decimal_from_float(float(config.margin_threshold))
|
||||||
hedge_budget = Money(amount=decimal_from_float(float(config.monthly_budget)), currency=BaseCurrency.USD)
|
hedge_budget = Money(amount=decimal_from_float(float(config.monthly_budget)), currency=BaseCurrency.USD)
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ def demo_spot_price() -> float:
|
|||||||
return 215.0
|
return 215.0
|
||||||
|
|
||||||
|
|
||||||
def portfolio_snapshot(config: PortfolioConfig | None = None) -> dict[str, float]:
|
def portfolio_snapshot(
|
||||||
return portfolio_snapshot_from_config(config)
|
config: PortfolioConfig | None = None,
|
||||||
|
*,
|
||||||
|
runtime_spot_price: float | None = None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
return portfolio_snapshot_from_config(config, runtime_spot_price=runtime_spot_price)
|
||||||
|
|
||||||
|
|
||||||
def strategy_catalog() -> list[dict[str, Any]]:
|
def strategy_catalog() -> list[dict[str, Any]]:
|
||||||
@@ -151,7 +155,9 @@ def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str |
|
|||||||
|
|
||||||
|
|
||||||
def render_workspace_recovery(title: str = "Workspace not found", message: str | None = None) -> None:
|
def render_workspace_recovery(title: str = "Workspace not found", message: str | None = None) -> None:
|
||||||
resolved_message = message or "The requested workspace is unavailable. Start a new workspace or return to the welcome page."
|
resolved_message = (
|
||||||
|
message or "The requested workspace is unavailable. Start a new workspace or return to the welcome page."
|
||||||
|
)
|
||||||
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
|
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
|
||||||
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
|
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
|
||||||
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
|
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||||
from app.models.workspace import get_workspace_repository
|
from app.models.workspace import get_workspace_repository
|
||||||
from app.pages.common import (
|
from app.pages.common import (
|
||||||
dashboard_page,
|
dashboard_page,
|
||||||
demo_spot_price,
|
demo_spot_price,
|
||||||
portfolio_snapshot,
|
portfolio_snapshot,
|
||||||
render_workspace_recovery,
|
|
||||||
strategy_catalog,
|
strategy_catalog,
|
||||||
strategy_metrics,
|
strategy_metrics,
|
||||||
)
|
)
|
||||||
|
from app.services.runtime import get_data_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _cost_benefit_options(metrics: dict) -> dict:
|
def _cost_benefit_options(metrics: dict) -> dict:
|
||||||
@@ -35,7 +40,6 @@ def _cost_benefit_options(metrics: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_options(metrics: dict) -> dict:
|
def _waterfall_options(metrics: dict) -> dict:
|
||||||
steps = metrics["waterfall_steps"]
|
steps = metrics["waterfall_steps"]
|
||||||
values: list[dict[str, object]] = []
|
values: list[dict[str, object]] = []
|
||||||
@@ -57,22 +61,49 @@ def _waterfall_options(metrics: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@ui.page("/{workspace_id}/hedge")
|
@ui.page("/{workspace_id}/hedge")
|
||||||
def workspace_hedge_page(workspace_id: str) -> None:
|
async def workspace_hedge_page(workspace_id: str) -> None:
|
||||||
repo = get_workspace_repository()
|
repo = get_workspace_repository()
|
||||||
if not repo.workspace_exists(workspace_id):
|
if not repo.workspace_exists(workspace_id):
|
||||||
return RedirectResponse(url="/", status_code=307)
|
return RedirectResponse(url="/", status_code=307)
|
||||||
_render_hedge_page(workspace_id=workspace_id)
|
await _render_hedge_page(workspace_id=workspace_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str, float], str, str]:
|
||||||
def _render_hedge_page(workspace_id: str | None = None) -> None:
|
"""Resolve hedge page spot price using the same quote-unit seam as overview."""
|
||||||
repo = get_workspace_repository()
|
repo = get_workspace_repository()
|
||||||
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
|
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
|
||||||
|
if config is None:
|
||||||
|
return {"spot_price": demo_spot_price()}, "demo", ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data_service = get_data_service()
|
||||||
|
quote = await data_service.get_quote(data_service.default_symbol)
|
||||||
|
spot, source, updated_at = resolve_portfolio_spot_from_quote(
|
||||||
|
config, quote, fallback_symbol=data_service.default_symbol
|
||||||
|
)
|
||||||
|
portfolio = portfolio_snapshot(config, runtime_spot_price=spot)
|
||||||
|
return portfolio, source, updated_at
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Falling back to configured hedge spot for workspace %s: %s", workspace_id, exc)
|
||||||
portfolio = portfolio_snapshot(config)
|
portfolio = portfolio_snapshot(config)
|
||||||
|
return portfolio, "configured_entry_price", ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_hedge_page(workspace_id: str | None = None) -> None:
|
||||||
|
repo = get_workspace_repository()
|
||||||
|
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
|
||||||
|
portfolio, quote_source, quote_updated_at = await _resolve_hedge_spot(workspace_id)
|
||||||
strategies = strategy_catalog()
|
strategies = strategy_catalog()
|
||||||
strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies}
|
strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies}
|
||||||
selected = {"strategy": strategies[0]["name"], "label": strategies[0]["label"], "scenario_pct": 0}
|
selected = {"strategy": strategies[0]["name"], "label": strategies[0]["label"], "scenario_pct": 0}
|
||||||
|
|
||||||
|
if quote_source == "configured_entry_price":
|
||||||
|
spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)"
|
||||||
|
else:
|
||||||
|
spot_label = (
|
||||||
|
f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (converted collateral spot via {quote_source})"
|
||||||
|
)
|
||||||
|
|
||||||
with dashboard_page(
|
with dashboard_page(
|
||||||
"Hedge Analysis",
|
"Hedge Analysis",
|
||||||
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.",
|
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.",
|
||||||
@@ -87,9 +118,7 @@ def _render_hedge_page(workspace_id: str | None = None) -> None:
|
|||||||
selector = ui.select(strategy_map, value=selected["label"], label="Strategy selector").classes("w-full")
|
selector = ui.select(strategy_map, value=selected["label"], label="Strategy selector").classes("w-full")
|
||||||
slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400")
|
slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full")
|
slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full")
|
||||||
ui.label(f"Current spot reference: ${portfolio['spot_price']:,.2f}").classes(
|
ui.label(spot_label).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
"text-sm text-slate-500 dark:text-slate-400"
|
|
||||||
)
|
|
||||||
if workspace_id:
|
if workspace_id:
|
||||||
ui.label(f"Workspace route: /{workspace_id}/hedge").classes(
|
ui.label(f"Workspace route: /{workspace_id}/hedge").classes(
|
||||||
"text-xs text-slate-500 dark:text-slate-400"
|
"text-xs text-slate-500 dark:text-slate-400"
|
||||||
|
|||||||
@@ -163,9 +163,17 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
second_workspace_url = second_page.url
|
second_workspace_url = second_page.url
|
||||||
assert second_workspace_url != workspace_url
|
assert second_workspace_url != workspace_url
|
||||||
second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)
|
second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
settings_loaded = False
|
||||||
|
for _ in range(3):
|
||||||
|
try:
|
||||||
expect(second_page).to_have_url(f"{second_workspace_url}/settings")
|
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.locator("text=Settings").first).to_be_visible(timeout=15000)
|
||||||
expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000", 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_page.close()
|
||||||
second_context.close()
|
second_context.close()
|
||||||
|
|
||||||
@@ -189,18 +197,21 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
assert "Weight" in hedge_text
|
assert "Weight" in hedge_text
|
||||||
assert "Loan amount" in hedge_text
|
assert "Loan amount" in hedge_text
|
||||||
assert "Monthly hedge budget" in hedge_text
|
assert "Monthly hedge budget" in hedge_text
|
||||||
assert "$968,000" in hedge_text
|
assert "$4,400.00/oz" not in hedge_text
|
||||||
assert "$4,400.00/oz" in hedge_text
|
|
||||||
assert "220 oz" in hedge_text
|
assert "220 oz" in hedge_text
|
||||||
assert "$222,000" in hedge_text
|
assert "$222,000" in hedge_text
|
||||||
assert "80.0%" in hedge_text
|
assert "80.0%" in hedge_text
|
||||||
assert "$12,345" 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 "Scenario spot" in hedge_text
|
||||||
assert "$3,520.00" in hedge_text
|
|
||||||
assert "Unhedged equity" in hedge_text
|
assert "Unhedged equity" in hedge_text
|
||||||
assert "$552,400" in hedge_text
|
|
||||||
assert "Hedged equity" 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.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()
|
browser.close()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
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.common import strategy_metrics
|
||||||
from app.pages.hedge import _waterfall_options
|
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"]
|
values = options["series"][0]["data"]
|
||||||
assert values[2]["value"] == 38_000.0
|
assert values[2]["value"] == 38_000.0
|
||||||
assert values[2]["itemStyle"]["color"] == "#22c55e"
|
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"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.domain.instruments import price_per_weight_from_asset_price
|
|||||||
from app.domain.portfolio_math import (
|
from app.domain.portfolio_math import (
|
||||||
build_alert_context,
|
build_alert_context,
|
||||||
portfolio_snapshot_from_config,
|
portfolio_snapshot_from_config,
|
||||||
|
resolve_portfolio_spot_from_quote,
|
||||||
strategy_metrics_from_snapshot,
|
strategy_metrics_from_snapshot,
|
||||||
)
|
)
|
||||||
from app.domain.units import BaseCurrency, WeightUnit
|
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),
|
("Hedge cost", -6250.0),
|
||||||
("Net equity", 58750.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"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.workspace import WorkspaceRepository
|
from app.models.workspace import WorkspaceRepository
|
||||||
|
from app.services.data_service import DataService
|
||||||
|
|
||||||
UUID4_RE = re.compile(
|
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}$",
|
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")
|
event_response = client.get(f"/{workspace_id}/event-comparison")
|
||||||
|
|
||||||
assert hedge_response.status_code == 200
|
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 backtests_response.status_code == 200
|
||||||
assert "Workspace defaults seed underlying units, loan amount, and margin threshold." in backtests_response.text
|
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 "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 "9,680" in event_response.text or "9680" in event_response.text
|
||||||
assert "80.0%" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user