BuyingAgent/backend/app/scoring/price.py

149 lines
5.2 KiB
Python
Raw Permalink Normal View History

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