149 lines
5.2 KiB
Python
149 lines
5.2 KiB
Python
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,
|
|
)
|