feat(CORE-001D): close remaining boundary cleanup slices
This commit is contained in:
@@ -13,6 +13,7 @@ from app.models.backtest import (
|
|||||||
)
|
)
|
||||||
from app.models.event_preset import EventPreset
|
from app.models.event_preset import EventPreset
|
||||||
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
||||||
|
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
|
||||||
from app.services.backtesting.service import BacktestService
|
from app.services.backtesting.service import BacktestService
|
||||||
from app.services.event_presets import EventPresetService
|
from app.services.event_presets import EventPresetService
|
||||||
from app.services.strategy_templates import StrategyTemplateService
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
@@ -64,16 +65,24 @@ class EventComparisonService:
|
|||||||
financing_rate: float = 0.0,
|
financing_rate: float = 0.0,
|
||||||
provider_ref: ProviderRef | None = None,
|
provider_ref: ProviderRef | None = None,
|
||||||
) -> EventComparisonReport:
|
) -> EventComparisonReport:
|
||||||
preset = self.event_preset_service.get_preset(preset_slug)
|
normalized_inputs = normalize_historical_scenario_inputs(
|
||||||
scenario = self.preview_scenario_from_inputs(
|
|
||||||
preset_slug=preset_slug,
|
|
||||||
underlying_units=underlying_units,
|
underlying_units=underlying_units,
|
||||||
loan_amount=loan_amount,
|
loan_amount=loan_amount,
|
||||||
margin_call_ltv=margin_call_ltv,
|
margin_call_ltv=margin_call_ltv,
|
||||||
template_slugs=template_slugs,
|
|
||||||
currency=currency,
|
currency=currency,
|
||||||
cash_balance=cash_balance,
|
cash_balance=cash_balance,
|
||||||
financing_rate=financing_rate,
|
financing_rate=financing_rate,
|
||||||
|
)
|
||||||
|
preset = self.event_preset_service.get_preset(preset_slug)
|
||||||
|
scenario = self.preview_scenario_from_inputs(
|
||||||
|
preset_slug=preset_slug,
|
||||||
|
underlying_units=normalized_inputs.underlying_units,
|
||||||
|
loan_amount=normalized_inputs.loan_amount,
|
||||||
|
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||||
|
template_slugs=template_slugs,
|
||||||
|
currency=normalized_inputs.currency,
|
||||||
|
cash_balance=normalized_inputs.cash_balance,
|
||||||
|
financing_rate=normalized_inputs.financing_rate,
|
||||||
provider_ref=provider_ref,
|
provider_ref=provider_ref,
|
||||||
)
|
)
|
||||||
return self._compare_materialized_event(preset=preset, scenario=scenario)
|
return self._compare_materialized_event(preset=preset, scenario=scenario)
|
||||||
@@ -91,19 +100,27 @@ class EventComparisonService:
|
|||||||
financing_rate: float = 0.0,
|
financing_rate: float = 0.0,
|
||||||
provider_ref: ProviderRef | None = None,
|
provider_ref: ProviderRef | None = None,
|
||||||
) -> BacktestScenario:
|
) -> BacktestScenario:
|
||||||
preset = self.event_preset_service.get_preset(preset_slug)
|
normalized_inputs = normalize_historical_scenario_inputs(
|
||||||
history = self._load_preset_history(preset)
|
|
||||||
entry_spot = history[0].close
|
|
||||||
initial_portfolio = materialize_backtest_portfolio_state(
|
|
||||||
symbol=preset.symbol,
|
|
||||||
underlying_units=underlying_units,
|
underlying_units=underlying_units,
|
||||||
entry_spot=entry_spot,
|
|
||||||
loan_amount=loan_amount,
|
loan_amount=loan_amount,
|
||||||
margin_call_ltv=margin_call_ltv,
|
margin_call_ltv=margin_call_ltv,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
cash_balance=cash_balance,
|
cash_balance=cash_balance,
|
||||||
financing_rate=financing_rate,
|
financing_rate=financing_rate,
|
||||||
)
|
)
|
||||||
|
preset = self.event_preset_service.get_preset(preset_slug)
|
||||||
|
history = self._load_preset_history(preset)
|
||||||
|
entry_spot = history[0].close
|
||||||
|
initial_portfolio = materialize_backtest_portfolio_state(
|
||||||
|
symbol=preset.symbol,
|
||||||
|
underlying_units=normalized_inputs.underlying_units,
|
||||||
|
entry_spot=entry_spot,
|
||||||
|
loan_amount=normalized_inputs.loan_amount,
|
||||||
|
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||||
|
currency=normalized_inputs.currency,
|
||||||
|
cash_balance=normalized_inputs.cash_balance,
|
||||||
|
financing_rate=normalized_inputs.financing_rate,
|
||||||
|
)
|
||||||
return self.materialize_scenario(
|
return self.materialize_scenario(
|
||||||
preset,
|
preset,
|
||||||
initial_portfolio=initial_portfolio,
|
initial_portfolio=initial_portfolio,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
from math import isfinite
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from app.models.backtest import ProviderRef
|
from app.models.backtest import ProviderRef
|
||||||
@@ -35,6 +36,24 @@ class SyntheticOptionQuote:
|
|||||||
quantity: float
|
quantity: float
|
||||||
mark: float
|
mark: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("position_id", "leg_id"):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError(f"{field_name} is required")
|
||||||
|
for field_name in ("spot", "strike", "quantity", "mark"):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(float(value)):
|
||||||
|
raise TypeError(f"{field_name} must be a finite number")
|
||||||
|
if self.spot <= 0:
|
||||||
|
raise ValueError("spot must be positive")
|
||||||
|
if self.strike <= 0:
|
||||||
|
raise ValueError("strike must be positive")
|
||||||
|
if self.quantity <= 0:
|
||||||
|
raise ValueError("quantity must be positive")
|
||||||
|
if self.mark < 0:
|
||||||
|
raise ValueError("mark must be non-negative")
|
||||||
|
|
||||||
|
|
||||||
class HistoricalPriceSource(Protocol):
|
class HistoricalPriceSource(Protocol):
|
||||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
@@ -42,6 +61,17 @@ class HistoricalPriceSource(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class YFinanceHistoricalPriceSource:
|
class YFinanceHistoricalPriceSource:
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_daily_close_row(*, row_date: object, close: object) -> DailyClosePoint | None:
|
||||||
|
if close is None:
|
||||||
|
return None
|
||||||
|
if not hasattr(row_date, "date"):
|
||||||
|
raise TypeError(f"historical row date must support .date(), got {type(row_date)!r}")
|
||||||
|
normalized_close = float(close)
|
||||||
|
if not isfinite(normalized_close):
|
||||||
|
raise ValueError("historical close must be finite")
|
||||||
|
return DailyClosePoint(date=row_date.date(), close=normalized_close)
|
||||||
|
|
||||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
if yf is None:
|
if yf is None:
|
||||||
raise RuntimeError("yfinance is required to load historical backtest prices")
|
raise RuntimeError("yfinance is required to load historical backtest prices")
|
||||||
@@ -50,10 +80,9 @@ class YFinanceHistoricalPriceSource:
|
|||||||
history = ticker.history(start=start_date.isoformat(), end=inclusive_end_date.isoformat(), interval="1d")
|
history = ticker.history(start=start_date.isoformat(), end=inclusive_end_date.isoformat(), interval="1d")
|
||||||
rows: list[DailyClosePoint] = []
|
rows: list[DailyClosePoint] = []
|
||||||
for index, row in history.iterrows():
|
for index, row in history.iterrows():
|
||||||
close = row.get("Close")
|
point = self._normalize_daily_close_row(row_date=index, close=row.get("Close"))
|
||||||
if close is None:
|
if point is not None:
|
||||||
continue
|
rows.append(point)
|
||||||
rows.append(DailyClosePoint(date=index.date(), close=float(close)))
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
app/services/backtesting/input_normalization.py
Normal file
51
app/services/backtesting/input_normalization.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.services.boundary_values import boundary_decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NormalizedHistoricalScenarioInputs:
|
||||||
|
underlying_units: float
|
||||||
|
loan_amount: float
|
||||||
|
margin_call_ltv: float
|
||||||
|
currency: str = "USD"
|
||||||
|
cash_balance: float = 0.0
|
||||||
|
financing_rate: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_historical_scenario_inputs(
|
||||||
|
*,
|
||||||
|
underlying_units: object,
|
||||||
|
loan_amount: object,
|
||||||
|
margin_call_ltv: object,
|
||||||
|
currency: object = "USD",
|
||||||
|
cash_balance: object = 0.0,
|
||||||
|
financing_rate: object = 0.0,
|
||||||
|
) -> NormalizedHistoricalScenarioInputs:
|
||||||
|
normalized_currency = str(currency).strip().upper()
|
||||||
|
if not normalized_currency:
|
||||||
|
raise ValueError("Currency is required")
|
||||||
|
|
||||||
|
units = float(boundary_decimal(underlying_units, field_name="underlying_units"))
|
||||||
|
normalized_loan_amount = float(boundary_decimal(loan_amount, field_name="loan_amount"))
|
||||||
|
ltv = float(boundary_decimal(margin_call_ltv, field_name="margin_call_ltv"))
|
||||||
|
normalized_cash_balance = float(boundary_decimal(cash_balance, field_name="cash_balance"))
|
||||||
|
normalized_financing_rate = float(boundary_decimal(financing_rate, field_name="financing_rate"))
|
||||||
|
|
||||||
|
if units <= 0:
|
||||||
|
raise ValueError("Underlying units must be positive")
|
||||||
|
if normalized_loan_amount < 0:
|
||||||
|
raise ValueError("Loan amount must be non-negative")
|
||||||
|
if not 0 < ltv < 1:
|
||||||
|
raise ValueError("Margin call LTV must be between 0 and 1")
|
||||||
|
|
||||||
|
return NormalizedHistoricalScenarioInputs(
|
||||||
|
underlying_units=units,
|
||||||
|
loan_amount=normalized_loan_amount,
|
||||||
|
margin_call_ltv=ltv,
|
||||||
|
currency=normalized_currency,
|
||||||
|
cash_balance=normalized_cash_balance,
|
||||||
|
financing_rate=normalized_financing_rate,
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ from app.services.backtesting.historical_provider import (
|
|||||||
SyntheticHistoricalProvider,
|
SyntheticHistoricalProvider,
|
||||||
SyntheticOptionQuote,
|
SyntheticOptionQuote,
|
||||||
)
|
)
|
||||||
|
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
|
||||||
from app.services.backtesting.service import BacktestService
|
from app.services.backtesting.service import BacktestService
|
||||||
from app.services.strategy_templates import StrategyTemplateService
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
|
|
||||||
@@ -149,12 +150,11 @@ class BacktestPageService:
|
|||||||
raise ValueError("BT-001A backtests are currently limited to GLD on this page")
|
raise ValueError("BT-001A backtests are currently limited to GLD on this page")
|
||||||
if start_date > end_date:
|
if start_date > end_date:
|
||||||
raise ValueError("Start date must be on or before end date")
|
raise ValueError("Start date must be on or before end date")
|
||||||
if underlying_units <= 0:
|
normalized_inputs = normalize_historical_scenario_inputs(
|
||||||
raise ValueError("Underlying units must be positive")
|
underlying_units=underlying_units,
|
||||||
if loan_amount < 0:
|
loan_amount=loan_amount,
|
||||||
raise ValueError("Loan amount must be non-negative")
|
margin_call_ltv=margin_call_ltv,
|
||||||
if not 0 < margin_call_ltv < 1:
|
)
|
||||||
raise ValueError("Margin call LTV must be between 0 and 1")
|
|
||||||
if not template_slug:
|
if not template_slug:
|
||||||
raise ValueError("Template selection is required")
|
raise ValueError("Template selection is required")
|
||||||
|
|
||||||
@@ -169,7 +169,11 @@ class BacktestPageService:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Supplied entry spot ${entry_spot:,.2f} does not match derived historical entry spot ${derived_entry_spot:,.2f}"
|
f"Supplied entry spot ${entry_spot:,.2f} does not match derived historical entry spot ${derived_entry_spot:,.2f}"
|
||||||
)
|
)
|
||||||
_validate_initial_collateral(underlying_units, derived_entry_spot, loan_amount)
|
_validate_initial_collateral(
|
||||||
|
normalized_inputs.underlying_units,
|
||||||
|
derived_entry_spot,
|
||||||
|
normalized_inputs.loan_amount,
|
||||||
|
)
|
||||||
return derived_entry_spot
|
return derived_entry_spot
|
||||||
|
|
||||||
def run_read_only_scenario(
|
def run_read_only_scenario(
|
||||||
@@ -193,13 +197,18 @@ class BacktestPageService:
|
|||||||
loan_amount=loan_amount,
|
loan_amount=loan_amount,
|
||||||
margin_call_ltv=margin_call_ltv,
|
margin_call_ltv=margin_call_ltv,
|
||||||
)
|
)
|
||||||
|
normalized_inputs = normalize_historical_scenario_inputs(
|
||||||
|
underlying_units=underlying_units,
|
||||||
|
loan_amount=loan_amount,
|
||||||
|
margin_call_ltv=margin_call_ltv,
|
||||||
|
)
|
||||||
template = self.template_service.get_template(template_slug)
|
template = self.template_service.get_template(template_slug)
|
||||||
initial_portfolio = materialize_backtest_portfolio_state(
|
initial_portfolio = materialize_backtest_portfolio_state(
|
||||||
symbol=normalized_symbol,
|
symbol=normalized_symbol,
|
||||||
underlying_units=underlying_units,
|
underlying_units=normalized_inputs.underlying_units,
|
||||||
entry_spot=entry_spot,
|
entry_spot=entry_spot,
|
||||||
loan_amount=loan_amount,
|
loan_amount=normalized_inputs.loan_amount,
|
||||||
margin_call_ltv=margin_call_ltv,
|
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||||
)
|
)
|
||||||
scenario = BacktestScenario(
|
scenario = BacktestScenario(
|
||||||
scenario_id=(
|
scenario_id=(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import date
|
|||||||
from app.models.backtest import BacktestScenario, EventComparisonReport
|
from app.models.backtest import BacktestScenario, EventComparisonReport
|
||||||
from app.services.backtesting.comparison import EventComparisonService
|
from app.services.backtesting.comparison import EventComparisonService
|
||||||
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
||||||
|
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
|
||||||
from app.services.event_presets import EventPresetService
|
from app.services.event_presets import EventPresetService
|
||||||
from app.services.strategy_templates import StrategyTemplateService
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
|
|
||||||
@@ -151,12 +152,12 @@ class EventComparisonPageService:
|
|||||||
raise ValueError("Preset selection is required")
|
raise ValueError("Preset selection is required")
|
||||||
if not template_slugs:
|
if not template_slugs:
|
||||||
raise ValueError("Select at least one strategy template.")
|
raise ValueError("Select at least one strategy template.")
|
||||||
if underlying_units <= 0:
|
|
||||||
raise ValueError("Underlying units must be positive")
|
normalized_inputs = normalize_historical_scenario_inputs(
|
||||||
if loan_amount < 0:
|
underlying_units=underlying_units,
|
||||||
raise ValueError("Loan amount must be non-negative")
|
loan_amount=loan_amount,
|
||||||
if not 0 < margin_call_ltv < 1:
|
margin_call_ltv=margin_call_ltv,
|
||||||
raise ValueError("Margin call LTV must be between 0 and 1")
|
)
|
||||||
|
|
||||||
preset = self.event_preset_service.get_preset(preset_slug)
|
preset = self.event_preset_service.get_preset(preset_slug)
|
||||||
normalized_symbol = preset.symbol.strip().upper()
|
normalized_symbol = preset.symbol.strip().upper()
|
||||||
@@ -179,15 +180,23 @@ class EventComparisonPageService:
|
|||||||
preset.window_end,
|
preset.window_end,
|
||||||
)
|
)
|
||||||
if preview_history:
|
if preview_history:
|
||||||
_validate_initial_collateral(underlying_units, preview_history[0].close, loan_amount)
|
_validate_initial_collateral(
|
||||||
|
normalized_inputs.underlying_units,
|
||||||
|
preview_history[0].close,
|
||||||
|
normalized_inputs.loan_amount,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
_validate_initial_collateral(underlying_units, preview.initial_portfolio.entry_spot, loan_amount)
|
_validate_initial_collateral(
|
||||||
|
normalized_inputs.underlying_units,
|
||||||
|
preview.initial_portfolio.entry_spot,
|
||||||
|
normalized_inputs.loan_amount,
|
||||||
|
)
|
||||||
return self.comparison_service.compare_event_from_inputs(
|
return self.comparison_service.compare_event_from_inputs(
|
||||||
preset_slug=preset.slug,
|
preset_slug=preset.slug,
|
||||||
template_slugs=template_slugs,
|
template_slugs=template_slugs,
|
||||||
underlying_units=underlying_units,
|
underlying_units=normalized_inputs.underlying_units,
|
||||||
loan_amount=loan_amount,
|
loan_amount=normalized_inputs.loan_amount,
|
||||||
margin_call_ltv=margin_call_ltv,
|
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Live price feed service for fetching real-time GLD and other asset prices."""
|
"""Live price feed service for fetching real-time GLD and other asset prices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Mapping
|
||||||
|
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
|
|
||||||
@@ -13,7 +16,7 @@ from app.services.cache import get_cache
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
class PriceData:
|
class PriceData:
|
||||||
"""Price data for a symbol."""
|
"""Price data for a symbol."""
|
||||||
|
|
||||||
@@ -23,6 +26,21 @@ class PriceData:
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
source: str = "yfinance"
|
source: str = "yfinance"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_symbol = self.symbol.strip().upper()
|
||||||
|
if not normalized_symbol:
|
||||||
|
raise ValueError("symbol is required")
|
||||||
|
if not math.isfinite(self.price) or self.price <= 0:
|
||||||
|
raise ValueError("price must be a finite positive number")
|
||||||
|
normalized_currency = self.currency.strip().upper()
|
||||||
|
if not normalized_currency:
|
||||||
|
raise ValueError("currency is required")
|
||||||
|
if not isinstance(self.timestamp, datetime):
|
||||||
|
raise TypeError("timestamp must be a datetime")
|
||||||
|
object.__setattr__(self, "symbol", normalized_symbol)
|
||||||
|
object.__setattr__(self, "currency", normalized_currency)
|
||||||
|
object.__setattr__(self, "source", self.source.strip() or "yfinance")
|
||||||
|
|
||||||
|
|
||||||
class PriceFeed:
|
class PriceFeed:
|
||||||
"""Live price feed service using yfinance with Redis caching."""
|
"""Live price feed service using yfinance with Redis caching."""
|
||||||
@@ -33,64 +51,104 @@ class PriceFeed:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._cache = get_cache()
|
self._cache = get_cache()
|
||||||
|
|
||||||
async def get_price(self, symbol: str) -> Optional[PriceData]:
|
@staticmethod
|
||||||
"""Get current price for a symbol, with caching.
|
def _normalize_cached_price_payload(payload: object, *, expected_symbol: str) -> PriceData:
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise TypeError("cached price payload must be an object")
|
||||||
|
payload_symbol = str(payload.get("symbol", expected_symbol)).strip().upper()
|
||||||
|
normalized_symbol = expected_symbol.strip().upper()
|
||||||
|
if payload_symbol != normalized_symbol:
|
||||||
|
raise ValueError(f"cached symbol mismatch: {payload_symbol} != {normalized_symbol}")
|
||||||
|
timestamp = payload.get("timestamp")
|
||||||
|
if not isinstance(timestamp, str) or not timestamp.strip():
|
||||||
|
raise TypeError("cached timestamp must be a non-empty ISO string")
|
||||||
|
return PriceData(
|
||||||
|
symbol=payload_symbol,
|
||||||
|
price=float(payload["price"]),
|
||||||
|
currency=str(payload.get("currency", "USD")),
|
||||||
|
timestamp=datetime.fromisoformat(timestamp),
|
||||||
|
source=str(payload.get("source", "yfinance")),
|
||||||
|
)
|
||||||
|
|
||||||
Args:
|
@staticmethod
|
||||||
symbol: Yahoo Finance symbol (e.g., "GLD", "BTC-USD")
|
def _normalize_provider_price_payload(payload: object, *, expected_symbol: str) -> PriceData:
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise TypeError("provider price payload must be an object")
|
||||||
|
payload_symbol = str(payload.get("symbol", expected_symbol)).strip().upper()
|
||||||
|
normalized_symbol = expected_symbol.strip().upper()
|
||||||
|
if payload_symbol != normalized_symbol:
|
||||||
|
raise ValueError(f"provider symbol mismatch: {payload_symbol} != {normalized_symbol}")
|
||||||
|
timestamp = payload.get("timestamp")
|
||||||
|
if not isinstance(timestamp, datetime):
|
||||||
|
raise TypeError("provider timestamp must be a datetime")
|
||||||
|
return PriceData(
|
||||||
|
symbol=payload_symbol,
|
||||||
|
price=float(payload["price"]),
|
||||||
|
currency=str(payload.get("currency", "USD")),
|
||||||
|
timestamp=timestamp,
|
||||||
|
source=str(payload.get("source", "yfinance")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _price_data_to_cache_payload(data: PriceData) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"symbol": data.symbol,
|
||||||
|
"price": data.price,
|
||||||
|
"currency": data.currency,
|
||||||
|
"timestamp": data.timestamp.isoformat(),
|
||||||
|
"source": data.source,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_price(self, symbol: str) -> PriceData | None:
|
||||||
|
"""Get current price for a symbol, with caching."""
|
||||||
|
normalized_symbol = symbol.strip().upper()
|
||||||
|
cache_key = f"price:{normalized_symbol}"
|
||||||
|
|
||||||
Returns:
|
|
||||||
PriceData or None if fetch fails
|
|
||||||
"""
|
|
||||||
# Check cache first
|
|
||||||
if self._cache.enabled:
|
if self._cache.enabled:
|
||||||
cache_key = f"price:{symbol}"
|
cached = await self._cache.get_json(cache_key)
|
||||||
cached = await self._cache.get(cache_key)
|
if cached is not None:
|
||||||
if cached:
|
try:
|
||||||
return PriceData(**cached)
|
return self._normalize_cached_price_payload(cached, expected_symbol=normalized_symbol)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
logger.warning("Discarding cached price payload for %s: %s", normalized_symbol, exc)
|
||||||
|
|
||||||
# Fetch from yfinance
|
|
||||||
try:
|
try:
|
||||||
data = await self._fetch_yfinance(symbol)
|
payload = await self._fetch_yfinance(normalized_symbol)
|
||||||
if data:
|
if payload is None:
|
||||||
# Cache the result
|
return None
|
||||||
if self._cache.enabled:
|
data = self._normalize_provider_price_payload(payload, expected_symbol=normalized_symbol)
|
||||||
await self._cache.set(
|
if self._cache.enabled:
|
||||||
cache_key,
|
await self._cache.set_json(
|
||||||
{
|
cache_key, self._price_data_to_cache_payload(data), ttl=self.CACHE_TTL_SECONDS
|
||||||
"symbol": data.symbol,
|
)
|
||||||
"price": data.price,
|
return data
|
||||||
"currency": data.currency,
|
except Exception as exc:
|
||||||
"timestamp": data.timestamp.isoformat(),
|
logger.error("Failed to fetch price for %s: %s", normalized_symbol, exc)
|
||||||
"source": data.source,
|
return None
|
||||||
},
|
|
||||||
ttl=self.CACHE_TTL_SECONDS,
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch price for {symbol}: {e}")
|
|
||||||
|
|
||||||
return None
|
async def _fetch_yfinance(self, symbol: str) -> dict[str, object] | None:
|
||||||
|
|
||||||
async def _fetch_yfinance(self, symbol: str) -> Optional[PriceData]:
|
|
||||||
"""Fetch price from yfinance (run in thread pool to avoid blocking)."""
|
"""Fetch price from yfinance (run in thread pool to avoid blocking)."""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
return await loop.run_in_executor(None, self._sync_fetch_yfinance, symbol)
|
return await loop.run_in_executor(None, self._sync_fetch_yfinance, symbol)
|
||||||
|
|
||||||
def _sync_fetch_yfinance(self, symbol: str) -> Optional[PriceData]:
|
def _sync_fetch_yfinance(self, symbol: str) -> dict[str, object] | None:
|
||||||
"""Synchronous yfinance fetch."""
|
"""Synchronous yfinance fetch."""
|
||||||
ticker = yf.Ticker(symbol)
|
ticker = yf.Ticker(symbol)
|
||||||
hist = ticker.history(period="1d", interval="1m")
|
hist = ticker.history(period="1d", interval="1m")
|
||||||
|
|
||||||
if not hist.empty:
|
if hist.empty:
|
||||||
last_price = hist["Close"].iloc[-1]
|
return None
|
||||||
currency = ticker.info.get("currency", "USD")
|
last_price = hist["Close"].iloc[-1]
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"price": float(last_price),
|
||||||
|
"currency": ticker.info.get("currency", "USD"),
|
||||||
|
"timestamp": datetime.utcnow(),
|
||||||
|
"source": "yfinance",
|
||||||
|
}
|
||||||
|
|
||||||
return PriceData(symbol=symbol, price=float(last_price), currency=currency, timestamp=datetime.utcnow())
|
async def get_prices(self, symbols: list[str]) -> dict[str, PriceData | None]:
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_prices(self, symbols: list[str]) -> dict[str, Optional[PriceData]]:
|
|
||||||
"""Get prices for multiple symbols concurrently."""
|
"""Get prices for multiple symbols concurrently."""
|
||||||
tasks = [self.get_price(s) for s in symbols]
|
tasks = [self.get_price(symbol) for symbol in symbols]
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
return {s: r for s, r in zip(symbols, results)}
|
return {symbol: result for symbol, result in zip(symbols, results, strict=True)}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ notes:
|
|||||||
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared.
|
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared.
|
||||||
- Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
|
- Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
|
||||||
priority_queue:
|
priority_queue:
|
||||||
- CORE-001D
|
|
||||||
- BT-003B
|
- BT-003B
|
||||||
- PORT-003
|
- PORT-003
|
||||||
- BT-002
|
- BT-002
|
||||||
@@ -25,6 +24,10 @@ priority_queue:
|
|||||||
- OPS-001
|
- OPS-001
|
||||||
- BT-003
|
- BT-003
|
||||||
recently_completed:
|
recently_completed:
|
||||||
|
- CORE-001D
|
||||||
|
- CORE-001D3C
|
||||||
|
- CORE-001D2D
|
||||||
|
- CORE-001D2C
|
||||||
- CORE-001D3B
|
- CORE-001D3B
|
||||||
- CORE-001D3A
|
- CORE-001D3A
|
||||||
- UX-001
|
- UX-001
|
||||||
@@ -45,7 +48,6 @@ states:
|
|||||||
- BT-003
|
- BT-003
|
||||||
- BT-003B
|
- BT-003B
|
||||||
- BT-001C
|
- BT-001C
|
||||||
- CORE-001D
|
|
||||||
in_progress: []
|
in_progress: []
|
||||||
done:
|
done:
|
||||||
- DATA-001
|
- DATA-001
|
||||||
@@ -64,10 +66,14 @@ states:
|
|||||||
- CORE-001A
|
- CORE-001A
|
||||||
- CORE-001B
|
- CORE-001B
|
||||||
- CORE-001C
|
- CORE-001C
|
||||||
|
- CORE-001D
|
||||||
- CORE-001D2A
|
- CORE-001D2A
|
||||||
- CORE-001D2B
|
- CORE-001D2B
|
||||||
|
- CORE-001D2C
|
||||||
|
- CORE-001D2D
|
||||||
- CORE-001D3A
|
- CORE-001D3A
|
||||||
- CORE-001D3B
|
- CORE-001D3B
|
||||||
|
- CORE-001D3C
|
||||||
- CORE-002
|
- CORE-002
|
||||||
- CORE-002A
|
- CORE-002A
|
||||||
- CORE-002B
|
- CORE-002B
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
id: CORE-001D
|
|
||||||
title: External Boundary and Persistence Cleanup for Decimal Unit Types
|
|
||||||
status: backlog
|
|
||||||
priority: P2
|
|
||||||
effort: M
|
|
||||||
depends_on:
|
|
||||||
- CORE-001B
|
|
||||||
- CORE-001C
|
|
||||||
tags: [core, decimal, persistence]
|
|
||||||
summary: Complete the remaining Decimal/unit-safe boundary cleanup after the shipped persistence seam.
|
|
||||||
acceptance_criteria:
|
|
||||||
- Provider and cache adapter boundaries are explicit, documented, and tested.
|
|
||||||
- Decimal-bearing JSON/API serialization expectations are documented for remaining external seams.
|
|
||||||
- Float-heavy service entrypoints are narrowed or wrapped in named normalization adapters.
|
|
||||||
- Remaining raw-float domain hotspots are identified or removed.
|
|
||||||
technical_notes:
|
|
||||||
- `CORE-001D1` is complete: portfolio/workspace persistence now uses an explicit unit-aware schema with strict validation and atomic saves.
|
|
||||||
- `CORE-001D2A` is complete: DataService quote/provider cache normalization is now a named boundary adapter with explicit symbol mismatch rejection and GLD quote-unit repair.
|
|
||||||
- `CORE-001D2B` is complete: option expirations and options-chain payloads now use explicit normalization boundaries with malformed cached payload discard/retry behavior.
|
|
||||||
- `CORE-001D3A` is complete: alert evaluation and settings save-status entrypoints now normalize float-heavy boundary values through explicit named adapters.
|
|
||||||
- `CORE-001D3B` is complete: corrupt alert-history storage now surfaces as an explicit degraded state with logging and route-visible notices instead of silently appearing as empty history.
|
|
||||||
- Remaining focus is the rest of `CORE-001D2` provider/cache normalization plus follow-on `CORE-001D3` service entrypoint tightening.
|
|
||||||
- Pre-launch policy: unit-aware schema changes may be breaking until persistence is considered live; old flat payloads may fail loudly instead of being migrated.
|
|
||||||
- See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices.
|
|
||||||
23
docs/roadmap/done/CORE-001D-decimal-boundary-cleanup.yaml
Normal file
23
docs/roadmap/done/CORE-001D-decimal-boundary-cleanup.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
id: CORE-001D
|
||||||
|
title: External Boundary and Persistence Cleanup for Decimal Unit Types
|
||||||
|
status: done
|
||||||
|
priority: P2
|
||||||
|
effort: M
|
||||||
|
depends_on:
|
||||||
|
- CORE-001B
|
||||||
|
- CORE-001C
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
- decimal
|
||||||
|
- persistence
|
||||||
|
summary: The remaining Decimal/unit-safe boundary cleanup slices have been completed across persistence, provider/cache adapters, alerts/settings, historical-provider boundaries, and historical scenario service entrypoints.
|
||||||
|
completed_notes:
|
||||||
|
- `CORE-001D1` made the portfolio/workspace persistence seam explicit with strict unit-aware schema validation and atomic saves.
|
||||||
|
- `CORE-001D2A` and `CORE-001D2B` normalized quote, option-expiration, and options-chain provider/cache boundaries in `DataService`.
|
||||||
|
- `CORE-001D2C` normalized `PriceFeed` cache/provider payloads through explicit adapters aligned with `CacheService`.
|
||||||
|
- `CORE-001D2D` normalized historical provider rows and synthetic option quote construction in `app/services/backtesting/historical_provider.py`.
|
||||||
|
- `CORE-001D3A` tightened alerts and settings save-status service entrypoints with named normalization adapters.
|
||||||
|
- `CORE-001D3B` turned corrupt alert-history storage into an explicit degraded state with logging and route-visible notices.
|
||||||
|
- `CORE-001D3C` normalized historical scenario inputs for backtests and event comparison through a shared adapter.
|
||||||
|
- Remaining raw-float dataclasses such as `LombardPortfolio`, `PriceData`, and MVP backtest result models are now treated as intentional edge-facing compatibility surfaces rather than undocumented internal seams.
|
||||||
|
- Validated through focused pytest coverage, browser-driven local Docker checks on the affected routes, and repeated `make build` runs across the shipped slices.
|
||||||
19
docs/roadmap/done/CORE-001D2C-price-feed-cache-boundary.yaml
Normal file
19
docs/roadmap/done/CORE-001D2C-price-feed-cache-boundary.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
id: CORE-001D2C
|
||||||
|
title: Price Feed Cache and Provider Normalization Boundary
|
||||||
|
status: done
|
||||||
|
priority: P2
|
||||||
|
effort: S
|
||||||
|
depends_on:
|
||||||
|
- CORE-001D2A
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
- decimal
|
||||||
|
- cache
|
||||||
|
- provider
|
||||||
|
summary: PriceFeed now uses explicit cache/provider payload normalization instead of ad-hoc raw dict handling.
|
||||||
|
completed_notes:
|
||||||
|
- Added explicit cached/provider payload normalization in `app/services/price_feed.py`.
|
||||||
|
- Removed the stale direct cache API usage and aligned `PriceFeed` with `CacheService.get_json(...)` / `set_json(...)`.
|
||||||
|
- Cached symbol mismatches and malformed cached payloads are discarded instead of being trusted.
|
||||||
|
- Added focused regression coverage in `tests/test_price_feed.py` for cache hits, malformed cache fallback, invalid provider payload rejection, and normalized cache writes.
|
||||||
|
- Validated with focused pytest coverage and `make build` on local Docker.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
id: CORE-001D2D
|
||||||
|
title: Historical Provider Row Normalization Boundary
|
||||||
|
status: done
|
||||||
|
priority: P2
|
||||||
|
effort: S
|
||||||
|
depends_on:
|
||||||
|
- CORE-001D2A
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
- decimal
|
||||||
|
- provider
|
||||||
|
- backtesting
|
||||||
|
summary: Historical provider row and synthetic option quote normalization are now explicit boundary adapters with focused validation.
|
||||||
|
completed_notes:
|
||||||
|
- Added explicit daily-close row normalization in `app/services/backtesting/historical_provider.py`.
|
||||||
|
- Malformed row dates and non-finite close values are now rejected at the provider boundary instead of leaking into backtest paths.
|
||||||
|
- Synthetic option quotes now validate required string and numeric fields on construction.
|
||||||
|
- Added focused regression coverage in `tests/test_backtesting.py` for normalized rows and invalid quote payloads.
|
||||||
|
- Validated with focused pytest coverage and `make build` on local Docker.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
id: CORE-001D3C
|
||||||
|
title: Historical Scenario Service Input Normalization
|
||||||
|
status: done
|
||||||
|
priority: P2
|
||||||
|
effort: S
|
||||||
|
depends_on:
|
||||||
|
- CORE-001C
|
||||||
|
- CORE-001D3A
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
- decimal
|
||||||
|
- backtesting
|
||||||
|
- event-comparison
|
||||||
|
summary: Backtest and event-comparison service entrypoints now normalize historical scenario inputs through a shared named adapter.
|
||||||
|
completed_notes:
|
||||||
|
- Added `normalize_historical_scenario_inputs(...)` in `app/services/backtesting/input_normalization.py`.
|
||||||
|
- `BacktestPageService`, `EventComparisonService`, and `EventComparisonPageService` now normalize units/loan/LTV inputs through the shared adapter instead of ad-hoc float checks.
|
||||||
|
- The shared adapter now accepts Decimal and numeric-string boundary values while still failing closed on invalid or non-finite inputs.
|
||||||
|
- Added focused regression coverage in `tests/test_backtest_ui.py` and `tests/test_event_comparison_ui.py`.
|
||||||
|
- Validated with focused pytest coverage, browser-driven checks on local Docker, and `make build`.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -10,6 +11,24 @@ from app.services.backtesting.ui_service import BacktestPageService
|
|||||||
from tests.helpers_backtest_sources import StaticBacktestSource
|
from tests.helpers_backtest_sources import StaticBacktestSource
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_page_service_accepts_decimal_boundary_values() -> None:
|
||||||
|
service = BacktestPageService()
|
||||||
|
|
||||||
|
result = service.run_read_only_scenario(
|
||||||
|
symbol="GLD",
|
||||||
|
start_date=date(2024, 1, 2),
|
||||||
|
end_date=date(2024, 1, 8),
|
||||||
|
template_slug="protective-put-atm-12m",
|
||||||
|
underlying_units=Decimal("1000.0"),
|
||||||
|
loan_amount=Decimal("68000.0"),
|
||||||
|
margin_call_ltv=Decimal("0.75"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario.initial_portfolio.underlying_units == 1000.0
|
||||||
|
assert result.scenario.initial_portfolio.loan_amount == 68000.0
|
||||||
|
assert result.scenario.initial_portfolio.margin_call_ltv == 0.75
|
||||||
|
|
||||||
|
|
||||||
def test_backtest_page_service_uses_fixture_window_for_deterministic_run() -> None:
|
def test_backtest_page_service_uses_fixture_window_for_deterministic_run() -> None:
|
||||||
service = BacktestPageService()
|
service = BacktestPageService()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.models.strategy_template import EntryPolicy, RollPolicy, StrategyTempla
|
|||||||
from app.services.backtesting.historical_provider import (
|
from app.services.backtesting.historical_provider import (
|
||||||
DailyClosePoint,
|
DailyClosePoint,
|
||||||
SyntheticHistoricalProvider,
|
SyntheticHistoricalProvider,
|
||||||
|
SyntheticOptionQuote,
|
||||||
YFinanceHistoricalPriceSource,
|
YFinanceHistoricalPriceSource,
|
||||||
)
|
)
|
||||||
from app.services.backtesting.service import BacktestService
|
from app.services.backtesting.service import BacktestService
|
||||||
@@ -209,6 +210,50 @@ def test_backtest_rejects_unsupported_template_behaviors(field: str, value: obje
|
|||||||
service._validate_template_for_mvp(unsupported_template)
|
service._validate_template_for_mvp(unsupported_template)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yfinance_price_source_normalizes_valid_daily_close_row() -> None:
|
||||||
|
point = YFinanceHistoricalPriceSource._normalize_daily_close_row(
|
||||||
|
row_date=pd.Timestamp("2024-01-03T00:00:00Z"),
|
||||||
|
close=96.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert point == DailyClosePoint(date=date(2024, 1, 3), close=96.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yfinance_price_source_rejects_invalid_daily_close_row_types() -> None:
|
||||||
|
with pytest.raises(TypeError, match="historical row date must support .date"):
|
||||||
|
YFinanceHistoricalPriceSource._normalize_daily_close_row(row_date="2024-01-03", close=96.0)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="historical close must be finite"):
|
||||||
|
YFinanceHistoricalPriceSource._normalize_daily_close_row(
|
||||||
|
row_date=pd.Timestamp("2024-01-03T00:00:00Z"),
|
||||||
|
close=float("nan"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthetic_option_quote_rejects_invalid_field_types() -> None:
|
||||||
|
with pytest.raises(TypeError, match="spot must be a finite number"):
|
||||||
|
SyntheticOptionQuote(
|
||||||
|
position_id="pos-1",
|
||||||
|
leg_id="leg-1",
|
||||||
|
spot="bad", # type: ignore[arg-type]
|
||||||
|
strike=100.0,
|
||||||
|
expiry=date(2025, 1, 1),
|
||||||
|
quantity=1.0,
|
||||||
|
mark=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="mark must be non-negative"):
|
||||||
|
SyntheticOptionQuote(
|
||||||
|
position_id="pos-1",
|
||||||
|
leg_id="leg-1",
|
||||||
|
spot=100.0,
|
||||||
|
strike=100.0,
|
||||||
|
expiry=date(2025, 1, 1),
|
||||||
|
quantity=1.0,
|
||||||
|
mark=-1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_yfinance_price_source_treats_end_date_inclusively(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_yfinance_price_source_treats_end_date_inclusively(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: list[tuple[str, str, str]] = []
|
calls: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.event_comparison_ui import EventComparisonFixtureHistoricalPriceSource, EventComparisonPageService
|
from app.services.event_comparison_ui import EventComparisonFixtureHistoricalPriceSource, EventComparisonPageService
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_comparison_page_service_accepts_string_and_decimal_boundary_values() -> None:
|
||||||
|
service = EventComparisonPageService()
|
||||||
|
|
||||||
|
report = service.run_read_only_comparison(
|
||||||
|
preset_slug="gld-jan-2024-selloff",
|
||||||
|
template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"),
|
||||||
|
underlying_units="1000.0",
|
||||||
|
loan_amount=Decimal("68000.0"),
|
||||||
|
margin_call_ltv="0.75",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.scenario.initial_portfolio.underlying_units == 1000.0
|
||||||
|
assert report.scenario.initial_portfolio.loan_amount == 68000.0
|
||||||
|
assert report.scenario.initial_portfolio.margin_call_ltv == 0.75
|
||||||
|
|
||||||
|
|
||||||
def test_event_comparison_page_service_runs_seeded_gld_preset_deterministically() -> None:
|
def test_event_comparison_page_service_runs_seeded_gld_preset_deterministically() -> None:
|
||||||
service = EventComparisonPageService()
|
service = EventComparisonPageService()
|
||||||
|
|
||||||
|
|||||||
128
tests/test_price_feed.py
Normal file
128
tests/test_price_feed.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.price_feed import PriceData, PriceFeed
|
||||||
|
|
||||||
|
|
||||||
|
class _CacheStub:
|
||||||
|
def __init__(self, payloads: dict[str, object] | None = None, enabled: bool = True) -> None:
|
||||||
|
self.payloads = payloads or {}
|
||||||
|
self.enabled = enabled
|
||||||
|
self.writes: list[tuple[str, object, int | None]] = []
|
||||||
|
|
||||||
|
async def get_json(self, key: str):
|
||||||
|
return self.payloads.get(key)
|
||||||
|
|
||||||
|
async def set_json(self, key: str, value, ttl: int | None = None):
|
||||||
|
self.payloads[key] = value
|
||||||
|
self.writes.append((key, value, ttl))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_feed_uses_normalized_cached_payload() -> None:
|
||||||
|
feed = PriceFeed()
|
||||||
|
feed._cache = _CacheStub(
|
||||||
|
{
|
||||||
|
"price:GLD": {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"price": 200.0,
|
||||||
|
"currency": "usd",
|
||||||
|
"timestamp": "2026-03-26T12:00:00+00:00",
|
||||||
|
"source": "cache",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await feed.get_price("gld")
|
||||||
|
|
||||||
|
assert data == PriceData(
|
||||||
|
symbol="GLD",
|
||||||
|
price=200.0,
|
||||||
|
currency="USD",
|
||||||
|
timestamp=datetime.fromisoformat("2026-03-26T12:00:00+00:00"),
|
||||||
|
source="cache",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_feed_discards_malformed_cached_payload_and_refetches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
feed = PriceFeed()
|
||||||
|
feed._cache = _CacheStub({"price:GLD": {"symbol": "TLT", "price": 200.0, "timestamp": "2026-03-26T12:00:00+00:00"}})
|
||||||
|
|
||||||
|
async def fake_fetch(symbol: str):
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"price": 201.5,
|
||||||
|
"currency": "USD",
|
||||||
|
"timestamp": datetime(2026, 3, 26, 12, 1, tzinfo=timezone.utc),
|
||||||
|
"source": "yfinance",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(feed, "_fetch_yfinance", fake_fetch)
|
||||||
|
|
||||||
|
data = await feed.get_price("GLD")
|
||||||
|
|
||||||
|
assert data == PriceData(
|
||||||
|
symbol="GLD",
|
||||||
|
price=201.5,
|
||||||
|
currency="USD",
|
||||||
|
timestamp=datetime(2026, 3, 26, 12, 1, tzinfo=timezone.utc),
|
||||||
|
source="yfinance",
|
||||||
|
)
|
||||||
|
assert feed._cache.writes[0][0] == "price:GLD"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_feed_rejects_invalid_provider_payload() -> None:
|
||||||
|
feed = PriceFeed()
|
||||||
|
feed._cache = _CacheStub(enabled=False)
|
||||||
|
|
||||||
|
async def fake_fetch(symbol: str):
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"price": 0.0,
|
||||||
|
"currency": "USD",
|
||||||
|
"timestamp": datetime(2026, 3, 26, 12, 1, tzinfo=timezone.utc),
|
||||||
|
"source": "yfinance",
|
||||||
|
}
|
||||||
|
|
||||||
|
feed._fetch_yfinance = fake_fetch # type: ignore[method-assign]
|
||||||
|
|
||||||
|
data = await feed.get_price("GLD")
|
||||||
|
|
||||||
|
assert data is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_feed_caches_normalized_provider_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
feed = PriceFeed()
|
||||||
|
feed._cache = _CacheStub()
|
||||||
|
|
||||||
|
async def fake_fetch(symbol: str):
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"price": 202.25,
|
||||||
|
"currency": "usd",
|
||||||
|
"timestamp": datetime(2026, 3, 26, 12, 2, tzinfo=timezone.utc),
|
||||||
|
"source": "yfinance",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(feed, "_fetch_yfinance", fake_fetch)
|
||||||
|
|
||||||
|
data = await feed.get_price("GLD")
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert data.currency == "USD"
|
||||||
|
cache_key, cache_payload, ttl = feed._cache.writes[0]
|
||||||
|
assert cache_key == "price:GLD"
|
||||||
|
assert ttl == PriceFeed.CACHE_TTL_SECONDS
|
||||||
|
assert cache_payload == {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"price": 202.25,
|
||||||
|
"currency": "USD",
|
||||||
|
"timestamp": "2026-03-26T12:02:00+00:00",
|
||||||
|
"source": "yfinance",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user