from collections.abc import Sequence from datetime import date, datetime, timedelta from decimal import Decimal from uuid import UUID from app.domain.enums import Availability, PriceBasis from app.domain.models import Budget, HistoryWindow, PriceObservation, PriceStatistics, ScoringModel from app.scoring.mathutil import clamp def total_or_none(observation: PriceObservation) -> Decimal | None: return observation.total_price def select_current_observation( observations: Sequence[PriceObservation], *, as_of: datetime, snapshot_cutoff: datetime, ) -> PriceObservation | None: eligible = [ item for item in observations if item.observed_at <= as_of and item.ingested_at <= snapshot_cutoff ] if not eligible: return None return max(eligible, key=lambda item: (item.observed_at, item.ingested_at, item.id)) def median(values: Sequence[Decimal]) -> Decimal | None: if not values: return None ordered = sorted(values) mid = len(ordered) // 2 if len(ordered) % 2: return ordered[mid] return (ordered[mid - 1] + ordered[mid]) / Decimal("2") def _quality_ok(observation: PriceObservation, minimum_confidence: Decimal) -> bool: return ( observation.availability == Availability.IN_STOCK and observation.total_price is not None and observation.confidence >= minimum_confidence ) def daily_series( observations: Sequence[PriceObservation], *, window_start: date, window_end_exclusive: date, minimum_confidence: Decimal, ) -> tuple[tuple[Decimal, ...], tuple[UUID, ...]]: by_day: dict[date, PriceObservation] = {} for item in observations: day = item.observed_at.date() if day < window_start or day >= window_end_exclusive: continue current = by_day.get(day) if current is None or (item.observed_at, item.ingested_at, item.id) > ( current.observed_at, current.ingested_at, current.id, ): by_day[day] = item values: list[Decimal] = [] ids: list[UUID] = [] for day in sorted(by_day): observation = by_day[day] if _quality_ok(observation, minimum_confidence): values.append(observation.total_price or Decimal("0")) ids.append(observation.id) return tuple(values), tuple(ids) def build_offer_reference_history( observations: Sequence[PriceObservation], current: PriceObservation, model: ScoringModel, ) -> dict[int, HistoryWindow]: current_day = current.observed_at.date() windows: dict[int, HistoryWindow] = {} for days in (30, 90, 365): values, ids = daily_series( observations, window_start=current_day - timedelta(days=days), window_end_exclusive=current_day, minimum_confidence=model.minimum_confidence, ) windows[days] = HistoryWindow(days=days, values=values, observation_ids=ids) return windows def score_price( *, current_price: Decimal, reference_history: Sequence[Decimal], budget: Budget, model: ScoringModel, ) -> tuple[Decimal, Decimal, Decimal | None, PriceBasis, str | None]: """Pure price score over current_price, reference_history and budget.""" if current_price <= budget.target: budget_score = Decimal("100") else: budget_score = clamp( Decimal("100") * (budget.maximum - current_price) / (budget.maximum - budget.target) ) if len(reference_history) < model.minimum_history_days: return budget_score, budget_score, None, PriceBasis.BUDGET_ONLY, "INSUFFICIENT_HISTORY" reference = median(reference_history) if reference is None or reference == 0: return budget_score, budget_score, None, PriceBasis.BUDGET_ONLY, "INSUFFICIENT_HISTORY" discount = (reference - current_price) / reference history_score = clamp( Decimal("50") + Decimal("50") * discount / model.history_discount_for_max_score ) price_score = model.budget_weight * budget_score + model.history_weight * history_score return price_score, budget_score, history_score, PriceBasis.BUDGET_AND_HISTORY, None def price_statistics_from_windows( current: PriceObservation | None, windows: dict[int, HistoryWindow], ) -> PriceStatistics: window_30 = windows.get(30, HistoryWindow(30, (), ())) window_90 = windows.get(90, HistoryWindow(90, (), ())) window_365 = windows.get(365, HistoryWindow(365, (), ())) used = window_30.observation_ids + window_90.observation_ids + window_365.observation_ids if current is not None: used = (current.id,) + used lowest = min(window_365.values) if window_365.values else None return PriceStatistics( current_total=None if current is None else current.total_price, median_30d=median(window_30.values), median_30d_days=window_30.day_count, median_90d=median(window_90.values), median_90d_days=window_90.day_count, lowest_365d=lowest, lowest_365d_days=window_365.day_count, lowest_365d_is_full_year=window_365.day_count >= 365, used_observation_ids=tuple(dict.fromkeys(used)), current_observation_id=None if current is None else current.id, )