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,
|
||||
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 (
|
||||
build_alert_context,
|
||||
portfolio_snapshot_from_config,
|
||||
resolve_portfolio_spot_from_quote,
|
||||
strategy_metrics_from_snapshot,
|
||||
)
|
||||
from app.domain.units import (
|
||||
@@ -35,5 +42,10 @@ __all__ = [
|
||||
"decimal_from_float",
|
||||
"portfolio_snapshot_from_config",
|
||||
"build_alert_context",
|
||||
"resolve_portfolio_spot_from_quote",
|
||||
"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 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.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)
|
||||
|
||||
|
||||
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]:
|
||||
if config is None:
|
||||
gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
Reference in New Issue
Block a user