diff --git a/app/services/backtesting/comparison.py b/app/services/backtesting/comparison.py index c7dc8d7..1527c34 100644 --- a/app/services/backtesting/comparison.py +++ b/app/services/backtesting/comparison.py @@ -13,6 +13,7 @@ from app.models.backtest import ( ) from app.models.event_preset import EventPreset 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.event_presets import EventPresetService from app.services.strategy_templates import StrategyTemplateService @@ -64,16 +65,24 @@ class EventComparisonService: financing_rate: float = 0.0, provider_ref: ProviderRef | None = None, ) -> EventComparisonReport: - preset = self.event_preset_service.get_preset(preset_slug) - scenario = self.preview_scenario_from_inputs( - preset_slug=preset_slug, + normalized_inputs = normalize_historical_scenario_inputs( underlying_units=underlying_units, loan_amount=loan_amount, margin_call_ltv=margin_call_ltv, - template_slugs=template_slugs, currency=currency, cash_balance=cash_balance, 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, ) return self._compare_materialized_event(preset=preset, scenario=scenario) @@ -91,19 +100,27 @@ class EventComparisonService: financing_rate: float = 0.0, provider_ref: ProviderRef | None = None, ) -> BacktestScenario: - 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, + normalized_inputs = normalize_historical_scenario_inputs( underlying_units=underlying_units, - entry_spot=entry_spot, loan_amount=loan_amount, margin_call_ltv=margin_call_ltv, currency=currency, cash_balance=cash_balance, 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( preset, initial_portfolio=initial_portfolio, diff --git a/app/services/backtesting/historical_provider.py b/app/services/backtesting/historical_provider.py index d92b754..5927ca5 100644 --- a/app/services/backtesting/historical_provider.py +++ b/app/services/backtesting/historical_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import date, timedelta +from math import isfinite from typing import Protocol from app.models.backtest import ProviderRef @@ -35,6 +36,24 @@ class SyntheticOptionQuote: quantity: 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): def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: @@ -42,6 +61,17 @@ class HistoricalPriceSource(Protocol): 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]: if yf is None: 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") rows: list[DailyClosePoint] = [] for index, row in history.iterrows(): - close = row.get("Close") - if close is None: - continue - rows.append(DailyClosePoint(date=index.date(), close=float(close))) + point = self._normalize_daily_close_row(row_date=index, close=row.get("Close")) + if point is not None: + rows.append(point) return rows diff --git a/app/services/backtesting/input_normalization.py b/app/services/backtesting/input_normalization.py new file mode 100644 index 0000000..9bfd6fc --- /dev/null +++ b/app/services/backtesting/input_normalization.py @@ -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, + ) diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index 5803681..eb1c2b1 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -18,6 +18,7 @@ from app.services.backtesting.historical_provider import ( SyntheticHistoricalProvider, SyntheticOptionQuote, ) +from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs from app.services.backtesting.service import BacktestService 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") if start_date > end_date: raise ValueError("Start date must be on or before end date") - if underlying_units <= 0: - raise ValueError("Underlying units must be positive") - if loan_amount < 0: - raise ValueError("Loan amount must be non-negative") - if not 0 < margin_call_ltv < 1: - raise ValueError("Margin call LTV must be between 0 and 1") + normalized_inputs = normalize_historical_scenario_inputs( + underlying_units=underlying_units, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + ) if not template_slug: raise ValueError("Template selection is required") @@ -169,7 +169,11 @@ class BacktestPageService: raise ValueError( 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 def run_read_only_scenario( @@ -193,13 +197,18 @@ class BacktestPageService: loan_amount=loan_amount, 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) initial_portfolio = materialize_backtest_portfolio_state( symbol=normalized_symbol, - underlying_units=underlying_units, + underlying_units=normalized_inputs.underlying_units, entry_spot=entry_spot, - loan_amount=loan_amount, - margin_call_ltv=margin_call_ltv, + loan_amount=normalized_inputs.loan_amount, + margin_call_ltv=normalized_inputs.margin_call_ltv, ) scenario = BacktestScenario( scenario_id=( diff --git a/app/services/event_comparison_ui.py b/app/services/event_comparison_ui.py index 33e3873..c286eb6 100644 --- a/app/services/event_comparison_ui.py +++ b/app/services/event_comparison_ui.py @@ -6,6 +6,7 @@ from datetime import date from app.models.backtest import BacktestScenario, EventComparisonReport from app.services.backtesting.comparison import EventComparisonService 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.strategy_templates import StrategyTemplateService @@ -151,12 +152,12 @@ class EventComparisonPageService: raise ValueError("Preset selection is required") if not template_slugs: raise ValueError("Select at least one strategy template.") - if underlying_units <= 0: - raise ValueError("Underlying units must be positive") - if loan_amount < 0: - raise ValueError("Loan amount must be non-negative") - if not 0 < margin_call_ltv < 1: - raise ValueError("Margin call LTV must be between 0 and 1") + + normalized_inputs = normalize_historical_scenario_inputs( + underlying_units=underlying_units, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + ) preset = self.event_preset_service.get_preset(preset_slug) normalized_symbol = preset.symbol.strip().upper() @@ -179,15 +180,23 @@ class EventComparisonPageService: preset.window_end, ) 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 - _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( preset_slug=preset.slug, template_slugs=template_slugs, - underlying_units=underlying_units, - loan_amount=loan_amount, - margin_call_ltv=margin_call_ltv, + underlying_units=normalized_inputs.underlying_units, + loan_amount=normalized_inputs.loan_amount, + margin_call_ltv=normalized_inputs.margin_call_ltv, ) @staticmethod diff --git a/app/services/price_feed.py b/app/services/price_feed.py index 0261488..9a08939 100644 --- a/app/services/price_feed.py +++ b/app/services/price_feed.py @@ -1,10 +1,13 @@ """Live price feed service for fetching real-time GLD and other asset prices.""" +from __future__ import annotations + import asyncio import logging +import math from dataclasses import dataclass from datetime import datetime -from typing import Optional +from typing import Mapping import yfinance as yf @@ -13,7 +16,7 @@ from app.services.cache import get_cache logger = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PriceData: """Price data for a symbol.""" @@ -23,6 +26,21 @@ class PriceData: timestamp: datetime 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: """Live price feed service using yfinance with Redis caching.""" @@ -33,64 +51,104 @@ class PriceFeed: def __init__(self): self._cache = get_cache() - async def get_price(self, symbol: str) -> Optional[PriceData]: - """Get current price for a symbol, with caching. + @staticmethod + 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: - symbol: Yahoo Finance symbol (e.g., "GLD", "BTC-USD") + @staticmethod + 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: - cache_key = f"price:{symbol}" - cached = await self._cache.get(cache_key) - if cached: - return PriceData(**cached) + cached = await self._cache.get_json(cache_key) + if cached is not None: + try: + 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: - data = await self._fetch_yfinance(symbol) - if data: - # Cache the result - if self._cache.enabled: - await self._cache.set( - cache_key, - { - "symbol": data.symbol, - "price": data.price, - "currency": data.currency, - "timestamp": data.timestamp.isoformat(), - "source": data.source, - }, - ttl=self.CACHE_TTL_SECONDS, - ) - return data - except Exception as e: - logger.error(f"Failed to fetch price for {symbol}: {e}") + payload = await self._fetch_yfinance(normalized_symbol) + if payload is None: + return None + data = self._normalize_provider_price_payload(payload, expected_symbol=normalized_symbol) + if self._cache.enabled: + await self._cache.set_json( + cache_key, self._price_data_to_cache_payload(data), ttl=self.CACHE_TTL_SECONDS + ) + return data + except Exception as exc: + logger.error("Failed to fetch price for %s: %s", normalized_symbol, exc) + return None - return None - - async def _fetch_yfinance(self, symbol: str) -> Optional[PriceData]: + async def _fetch_yfinance(self, symbol: str) -> dict[str, object] | None: """Fetch price from yfinance (run in thread pool to avoid blocking).""" loop = asyncio.get_event_loop() 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.""" ticker = yf.Ticker(symbol) hist = ticker.history(period="1d", interval="1m") - if not hist.empty: - last_price = hist["Close"].iloc[-1] - currency = ticker.info.get("currency", "USD") + if hist.empty: + return None + 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()) - return None - - async def get_prices(self, symbols: list[str]) -> dict[str, Optional[PriceData]]: + async def get_prices(self, symbols: list[str]) -> dict[str, PriceData | None]: """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) - return {s: r for s, r in zip(symbols, results)} + return {symbol: result for symbol, result in zip(symbols, results, strict=True)} diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 8770212..e11c8c6 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -13,7 +13,6 @@ notes: - 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. priority_queue: - - CORE-001D - BT-003B - PORT-003 - BT-002 @@ -25,6 +24,10 @@ priority_queue: - OPS-001 - BT-003 recently_completed: + - CORE-001D + - CORE-001D3C + - CORE-001D2D + - CORE-001D2C - CORE-001D3B - CORE-001D3A - UX-001 @@ -45,7 +48,6 @@ states: - BT-003 - BT-003B - BT-001C - - CORE-001D in_progress: [] done: - DATA-001 @@ -64,10 +66,14 @@ states: - CORE-001A - CORE-001B - CORE-001C + - CORE-001D - CORE-001D2A - CORE-001D2B + - CORE-001D2C + - CORE-001D2D - CORE-001D3A - CORE-001D3B + - CORE-001D3C - CORE-002 - CORE-002A - CORE-002B diff --git a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml deleted file mode 100644 index 27615f0..0000000 --- a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml +++ /dev/null @@ -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. diff --git a/docs/roadmap/done/CORE-001D-decimal-boundary-cleanup.yaml b/docs/roadmap/done/CORE-001D-decimal-boundary-cleanup.yaml new file mode 100644 index 0000000..3b17374 --- /dev/null +++ b/docs/roadmap/done/CORE-001D-decimal-boundary-cleanup.yaml @@ -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. diff --git a/docs/roadmap/done/CORE-001D2C-price-feed-cache-boundary.yaml b/docs/roadmap/done/CORE-001D2C-price-feed-cache-boundary.yaml new file mode 100644 index 0000000..4d907ff --- /dev/null +++ b/docs/roadmap/done/CORE-001D2C-price-feed-cache-boundary.yaml @@ -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. diff --git a/docs/roadmap/done/CORE-001D2D-historical-provider-boundary.yaml b/docs/roadmap/done/CORE-001D2D-historical-provider-boundary.yaml new file mode 100644 index 0000000..fe0de41 --- /dev/null +++ b/docs/roadmap/done/CORE-001D2D-historical-provider-boundary.yaml @@ -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. diff --git a/docs/roadmap/done/CORE-001D3C-historical-scenario-input-normalization.yaml b/docs/roadmap/done/CORE-001D3C-historical-scenario-input-normalization.yaml new file mode 100644 index 0000000..eff89c4 --- /dev/null +++ b/docs/roadmap/done/CORE-001D3C-historical-scenario-input-normalization.yaml @@ -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`. diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index 8d1c5a9..275459e 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date +from decimal import Decimal import pytest @@ -10,6 +11,24 @@ from app.services.backtesting.ui_service import BacktestPageService 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: service = BacktestPageService() diff --git a/tests/test_backtesting.py b/tests/test_backtesting.py index 930a45f..f3cc565 100644 --- a/tests/test_backtesting.py +++ b/tests/test_backtesting.py @@ -10,6 +10,7 @@ from app.models.strategy_template import EntryPolicy, RollPolicy, StrategyTempla from app.services.backtesting.historical_provider import ( DailyClosePoint, SyntheticHistoricalProvider, + SyntheticOptionQuote, YFinanceHistoricalPriceSource, ) 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) +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: calls: list[tuple[str, str, str]] = [] diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py index 15239fa..d45309b 100644 --- a/tests/test_event_comparison_ui.py +++ b/tests/test_event_comparison_ui.py @@ -1,12 +1,29 @@ from __future__ import annotations from datetime import date +from decimal import Decimal import pytest 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: service = EventComparisonPageService() diff --git a/tests/test_price_feed.py b/tests/test_price_feed.py new file mode 100644 index 0000000..cb24f3f --- /dev/null +++ b/tests/test_price_feed.py @@ -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", + }