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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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