feat(CORE-001D): close remaining boundary cleanup slices

This commit is contained in:
Bu5hm4nn
2026-03-26 17:27:44 +01:00
parent 99d22302ee
commit 94f3c1ef83
16 changed files with 552 additions and 107 deletions

View File

@@ -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,

View File

@@ -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

View 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,
)

View File

@@ -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=(

View File

@@ -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

View File

@@ -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)}

View File

@@ -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

View File

@@ -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.

View 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.

View 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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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()

View File

@@ -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]] = []

View File

@@ -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()

128
tests/test_price_feed.py Normal file
View 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",
}