feat(CORE-002): add GLD share quote conversion seam
This commit is contained in:
@@ -5,9 +5,16 @@ from app.domain.backtesting_math import (
|
|||||||
asset_quantity_from_money,
|
asset_quantity_from_money,
|
||||||
materialize_backtest_portfolio_state,
|
materialize_backtest_portfolio_state,
|
||||||
)
|
)
|
||||||
|
from app.domain.instruments import (
|
||||||
|
asset_quantity_from_weight,
|
||||||
|
instrument_metadata,
|
||||||
|
price_per_weight_from_asset_price,
|
||||||
|
weight_from_asset_quantity,
|
||||||
|
)
|
||||||
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 (
|
from app.domain.units import (
|
||||||
@@ -35,5 +42,10 @@ __all__ = [
|
|||||||
"decimal_from_float",
|
"decimal_from_float",
|
||||||
"portfolio_snapshot_from_config",
|
"portfolio_snapshot_from_config",
|
||||||
"build_alert_context",
|
"build_alert_context",
|
||||||
|
"resolve_portfolio_spot_from_quote",
|
||||||
"strategy_metrics_from_snapshot",
|
"strategy_metrics_from_snapshot",
|
||||||
|
"instrument_metadata",
|
||||||
|
"price_per_weight_from_asset_price",
|
||||||
|
"weight_from_asset_quantity",
|
||||||
|
"asset_quantity_from_weight",
|
||||||
]
|
]
|
||||||
|
|||||||
96
app/domain/instruments.py
Normal file
96
app/domain/instruments.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import AssetQuantity, PricePerAsset
|
||||||
|
from app.domain.units import BaseCurrency, PricePerWeight, Weight, WeightUnit
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InstrumentMetadata:
|
||||||
|
symbol: str
|
||||||
|
quote_currency: BaseCurrency | str
|
||||||
|
weight_per_share: Weight
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_symbol = str(self.symbol).strip().upper()
|
||||||
|
if not normalized_symbol:
|
||||||
|
raise ValueError("Instrument symbol is required")
|
||||||
|
object.__setattr__(self, "symbol", normalized_symbol)
|
||||||
|
object.__setattr__(self, "quote_currency", BaseCurrency(self.quote_currency))
|
||||||
|
object.__setattr__(self, "weight_per_share", self.weight_per_share)
|
||||||
|
|
||||||
|
def assert_symbol(self, symbol: str) -> InstrumentMetadata:
|
||||||
|
normalized = str(symbol).strip().upper()
|
||||||
|
if self.symbol != normalized:
|
||||||
|
raise ValueError(f"Instrument symbol mismatch: {self.symbol} != {normalized}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def assert_currency(self, currency: BaseCurrency | str) -> InstrumentMetadata:
|
||||||
|
normalized = BaseCurrency(currency)
|
||||||
|
if self.quote_currency is not normalized:
|
||||||
|
raise ValueError(f"Instrument currency mismatch: {self.quote_currency} != {normalized}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def price_per_weight_from_asset_price(
|
||||||
|
self,
|
||||||
|
price: PricePerAsset,
|
||||||
|
*,
|
||||||
|
per_unit: WeightUnit = WeightUnit.OUNCE_TROY,
|
||||||
|
) -> PricePerWeight:
|
||||||
|
self.assert_symbol(price.symbol)
|
||||||
|
self.assert_currency(price.currency)
|
||||||
|
weight_per_share = self.weight_per_share.to_unit(per_unit)
|
||||||
|
if weight_per_share.amount <= 0:
|
||||||
|
raise ValueError("Instrument weight_per_share must be positive")
|
||||||
|
return PricePerWeight(
|
||||||
|
amount=price.amount / weight_per_share.amount,
|
||||||
|
currency=price.currency,
|
||||||
|
per_unit=per_unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def weight_from_asset_quantity(self, quantity: AssetQuantity) -> Weight:
|
||||||
|
self.assert_symbol(quantity.symbol)
|
||||||
|
return Weight(amount=quantity.amount * self.weight_per_share.amount, unit=self.weight_per_share.unit)
|
||||||
|
|
||||||
|
def asset_quantity_from_weight(self, weight: Weight) -> AssetQuantity:
|
||||||
|
normalized_weight = weight.to_unit(self.weight_per_share.unit)
|
||||||
|
if self.weight_per_share.amount <= 0:
|
||||||
|
raise ValueError("Instrument weight_per_share must be positive")
|
||||||
|
return AssetQuantity(amount=normalized_weight.amount / self.weight_per_share.amount, symbol=self.symbol)
|
||||||
|
|
||||||
|
|
||||||
|
_GLD = InstrumentMetadata(
|
||||||
|
symbol="GLD",
|
||||||
|
quote_currency=BaseCurrency.USD,
|
||||||
|
weight_per_share=Weight(amount=Decimal("0.1"), unit=WeightUnit.OUNCE_TROY),
|
||||||
|
)
|
||||||
|
|
||||||
|
_INSTRUMENTS: dict[str, InstrumentMetadata] = {
|
||||||
|
_GLD.symbol: _GLD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def instrument_metadata(symbol: str) -> InstrumentMetadata:
|
||||||
|
normalized = str(symbol).strip().upper()
|
||||||
|
metadata = _INSTRUMENTS.get(normalized)
|
||||||
|
if metadata is None:
|
||||||
|
raise ValueError(f"Unsupported instrument metadata: {normalized or symbol!r}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def price_per_weight_from_asset_price(
|
||||||
|
price: PricePerAsset,
|
||||||
|
*,
|
||||||
|
per_unit: WeightUnit = WeightUnit.OUNCE_TROY,
|
||||||
|
) -> PricePerWeight:
|
||||||
|
return instrument_metadata(price.symbol).price_per_weight_from_asset_price(price, per_unit=per_unit)
|
||||||
|
|
||||||
|
|
||||||
|
def weight_from_asset_quantity(quantity: AssetQuantity) -> Weight:
|
||||||
|
return instrument_metadata(quantity.symbol).weight_from_asset_quantity(quantity)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_quantity_from_weight(symbol: str, weight: Weight) -> AssetQuantity:
|
||||||
|
return instrument_metadata(symbol).asset_quantity_from_weight(weight)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from app.domain.backtesting_math import PricePerAsset
|
||||||
|
from app.domain.instruments import instrument_metadata
|
||||||
from app.domain.units import BaseCurrency, Money, PricePerWeight, Weight, WeightUnit, decimal_from_float
|
from app.domain.units import BaseCurrency, Money, PricePerWeight, Weight, WeightUnit, decimal_from_float
|
||||||
from app.models.portfolio import PortfolioConfig
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
@@ -42,6 +44,44 @@ def _gold_weight(gold_ounces: float) -> Weight:
|
|||||||
return Weight(amount=decimal_from_float(gold_ounces), unit=WeightUnit.OUNCE_TROY)
|
return Weight(amount=decimal_from_float(gold_ounces), unit=WeightUnit.OUNCE_TROY)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_quote_price(value: object) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
if parsed <= 0:
|
||||||
|
return 0.0
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_portfolio_spot_from_quote(
|
||||||
|
config: PortfolioConfig,
|
||||||
|
quote: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
fallback_symbol: str | None = None,
|
||||||
|
) -> tuple[float, str, str]:
|
||||||
|
configured_price = float(config.entry_price or 0.0)
|
||||||
|
quote_price = _safe_quote_price(quote.get("price"))
|
||||||
|
quote_source = str(quote.get("source", "unknown"))
|
||||||
|
quote_updated_at = str(quote.get("updated_at", ""))
|
||||||
|
quote_symbol = str(quote.get("symbol", fallback_symbol or "")).strip().upper()
|
||||||
|
quote_unit = str(quote.get("quote_unit", "")).strip().lower()
|
||||||
|
|
||||||
|
if quote_price <= 0 or not quote_symbol or quote_unit != "share":
|
||||||
|
return configured_price, "configured_entry_price", ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = instrument_metadata(quote_symbol)
|
||||||
|
except ValueError:
|
||||||
|
return configured_price, "configured_entry_price", ""
|
||||||
|
|
||||||
|
converted_spot = metadata.price_per_weight_from_asset_price(
|
||||||
|
PricePerAsset(amount=decimal_from_float(quote_price), currency=BaseCurrency.USD, symbol=quote_symbol),
|
||||||
|
per_unit=WeightUnit.OUNCE_TROY,
|
||||||
|
)
|
||||||
|
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) -> 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)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse
|
|||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from app.components import PortfolioOverview
|
from app.components import PortfolioOverview
|
||||||
|
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||||
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
|
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
|
||||||
from app.services.alerts import AlertService, build_portfolio_alert_context
|
from app.services.alerts import AlertService, build_portfolio_alert_context
|
||||||
@@ -16,20 +17,10 @@ from app.services.turnstile import load_turnstile_settings
|
|||||||
_DEFAULT_CASH_BUFFER = 18_500.0
|
_DEFAULT_CASH_BUFFER = 18_500.0
|
||||||
|
|
||||||
|
|
||||||
def _resolve_overview_spot(config, quote: dict[str, object]) -> tuple[float, str, str]:
|
def _resolve_overview_spot(
|
||||||
configured_price = float(config.entry_price or 0.0)
|
config, quote: dict[str, object], *, fallback_symbol: str | None = None
|
||||||
quote_price = float(quote.get("price", configured_price or 0.0) or 0.0)
|
) -> tuple[float, str, str]:
|
||||||
quote_source = str(quote.get("source", "unknown"))
|
return resolve_portfolio_spot_from_quote(config, quote, fallback_symbol=fallback_symbol)
|
||||||
quote_updated_at = str(quote.get("updated_at", ""))
|
|
||||||
|
|
||||||
if configured_price > 0 and quote_price > 0:
|
|
||||||
ratio = max(configured_price / quote_price, quote_price / configured_price)
|
|
||||||
if ratio > 1.5:
|
|
||||||
return configured_price, "configured_entry_price", ""
|
|
||||||
|
|
||||||
if quote_price > 0:
|
|
||||||
return quote_price, quote_source, quote_updated_at
|
|
||||||
return configured_price, "configured_entry_price", ""
|
|
||||||
|
|
||||||
|
|
||||||
def _format_timestamp(value: str | None) -> str:
|
def _format_timestamp(value: str | None) -> str:
|
||||||
@@ -116,7 +107,9 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
data_service = get_data_service()
|
data_service = get_data_service()
|
||||||
symbol = data_service.default_symbol
|
symbol = data_service.default_symbol
|
||||||
quote = await data_service.get_quote(symbol)
|
quote = await data_service.get_quote(symbol)
|
||||||
overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot(config, quote)
|
overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot(
|
||||||
|
config, quote, fallback_symbol=symbol
|
||||||
|
)
|
||||||
portfolio = build_portfolio_alert_context(
|
portfolio = build_portfolio_alert_context(
|
||||||
config,
|
config,
|
||||||
spot_price=overview_spot_price,
|
spot_price=overview_spot_price,
|
||||||
@@ -132,6 +125,7 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
else:
|
else:
|
||||||
quote_status = (
|
quote_status = (
|
||||||
f"Live quote source: {portfolio['quote_source']} · "
|
f"Live quote source: {portfolio['quote_source']} · "
|
||||||
|
f"GLD share quote converted to ozt-equivalent spot · "
|
||||||
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
|
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,15 +155,21 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
if alert_status.history:
|
if alert_status.history:
|
||||||
latest = alert_status.history[0]
|
latest = alert_status.history[0]
|
||||||
ui.label(
|
ui.label(
|
||||||
f"Latest alert logged {_format_timestamp(latest.updated_at)} at spot ${latest.spot_price:,.2f}"
|
f"Latest alert logged {_format_timestamp(latest.updated_at)} at collateral spot ${latest.spot_price:,.2f}"
|
||||||
).classes("text-xs text-slate-500 dark:text-slate-400")
|
).classes("text-xs text-slate-500 dark:text-slate-400")
|
||||||
|
|
||||||
|
spot_caption = (
|
||||||
|
f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}"
|
||||||
|
if portfolio["quote_source"] != "configured_entry_price"
|
||||||
|
else "Configured entry price fallback in USD/ozt"
|
||||||
|
)
|
||||||
|
|
||||||
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
|
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
|
||||||
summary_cards = [
|
summary_cards = [
|
||||||
(
|
(
|
||||||
"Spot Price",
|
"Collateral Spot Price",
|
||||||
f"${portfolio['spot_price']:,.2f}",
|
f"${portfolio['spot_price']:,.2f}",
|
||||||
f"{symbol} live quote via {portfolio['quote_source']}",
|
spot_caption,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Margin Call Price",
|
"Margin Call Price",
|
||||||
|
|||||||
@@ -49,12 +49,16 @@ class DataService:
|
|||||||
return portfolio
|
return portfolio
|
||||||
|
|
||||||
async def get_quote(self, symbol: str) -> dict[str, Any]:
|
async def get_quote(self, symbol: str) -> dict[str, Any]:
|
||||||
cache_key = f"quote:{symbol}"
|
normalized_symbol = symbol.upper()
|
||||||
|
cache_key = f"quote:{normalized_symbol}"
|
||||||
cached = await self.cache.get_json(cache_key)
|
cached = await self.cache.get_json(cache_key)
|
||||||
if cached and isinstance(cached, dict):
|
if cached and isinstance(cached, dict):
|
||||||
return cached
|
normalized_cached = self._normalize_quote_payload(cached, normalized_symbol)
|
||||||
|
if normalized_cached != cached:
|
||||||
|
await self.cache.set_json(cache_key, normalized_cached)
|
||||||
|
return normalized_cached
|
||||||
|
|
||||||
quote = await self._fetch_quote(symbol)
|
quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol)
|
||||||
await self.cache.set_json(cache_key, quote)
|
await self.cache.set_json(cache_key, quote)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
@@ -250,6 +254,7 @@ class DataService:
|
|||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"price": round(last, 4),
|
"price": round(last, 4),
|
||||||
|
"quote_unit": "share",
|
||||||
"change": change,
|
"change": change,
|
||||||
"change_percent": change_percent,
|
"change_percent": change_percent,
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -358,11 +363,21 @@ class DataService:
|
|||||||
return round((bid + ask) / 2, 4)
|
return round((bid + ask) / 2, 4)
|
||||||
return max(bid, ask, 0.0)
|
return max(bid, ask, 0.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_quote_payload(payload: dict[str, Any], symbol: str) -> dict[str, Any]:
|
||||||
|
normalized = dict(payload)
|
||||||
|
normalized_symbol = symbol.upper()
|
||||||
|
normalized["symbol"] = str(normalized.get("symbol", normalized_symbol)).upper()
|
||||||
|
if normalized["symbol"] == "GLD" and not normalized.get("quote_unit"):
|
||||||
|
normalized["quote_unit"] = "share"
|
||||||
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
|
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"price": 215.0,
|
"price": 215.0,
|
||||||
|
"quote_unit": "share",
|
||||||
"change": 0.0,
|
"change": 0.0,
|
||||||
"change_percent": 0.0,
|
"change_percent": 0.0,
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
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 "Options Chain" in overview_text
|
||||||
assert "Backtests" in overview_text
|
assert "Backtests" in overview_text
|
||||||
assert "Event Comparison" 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 "$1,261.36" in overview_text
|
||||||
assert "$968,000.00" in overview_text
|
assert "$968,000" in overview_text
|
||||||
assert "$746,000.00" 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="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="Backtests")).to_have_attribute("href", f"/{workspace_id}/backtests")
|
||||||
expect(page.get_by_role("link", name="Event Comparison")).to_have_attribute(
|
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
|
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)
|
||||||
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_page.close()
|
||||||
second_context.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 __future__ import annotations
|
||||||
|
|
||||||
|
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||||
from app.models.portfolio import PortfolioConfig
|
from app.models.portfolio import PortfolioConfig
|
||||||
from app.pages.overview import _resolve_overview_spot
|
|
||||||
from app.services.alerts import build_portfolio_alert_context
|
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)
|
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,
|
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(
|
portfolio = build_portfolio_alert_context(
|
||||||
config,
|
config,
|
||||||
@@ -19,8 +25,90 @@ def test_overview_falls_back_to_configured_entry_price_when_live_quote_units_do_
|
|||||||
updated_at=updated_at,
|
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 spot_price == 4400.0
|
||||||
assert source == "configured_entry_price"
|
assert source == "configured_entry_price"
|
||||||
assert portfolio["gold_value"] == 968000.0
|
assert updated_at == ""
|
||||||
assert portfolio["net_equity"] == 823000.0
|
|
||||||
assert round(float(portfolio["margin_call_price"]), 2) == 878.79
|
|
||||||
|
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 __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 (
|
from app.domain.portfolio_math import (
|
||||||
build_alert_context,
|
build_alert_context,
|
||||||
portfolio_snapshot_from_config,
|
portfolio_snapshot_from_config,
|
||||||
strategy_metrics_from_snapshot,
|
strategy_metrics_from_snapshot,
|
||||||
)
|
)
|
||||||
|
from app.domain.units import BaseCurrency, WeightUnit
|
||||||
from app.models.portfolio import PortfolioConfig
|
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:
|
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
|
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:
|
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")
|
strategy = next(item for item in strategy_catalog() if item["name"] == "protective_put_atm")
|
||||||
snapshot = {
|
snapshot = {
|
||||||
"gold_value": 215000.0,
|
"gold_value": 215000.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user