181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""Score severity, label a diagnosis, and price the lost opportunity.
|
|
|
|
The money model is deliberately conservative and never invents a figure. If a
|
|
campaign's daily budget was not observed in the export, `lost` stays None and
|
|
the report writes an empty cell -- a zero would become a fact the moment
|
|
someone summed the column.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .scoring import CampaignDay
|
|
|
|
DEFAULT_ROAS_HAIRCUT = 0.70 # incremental budget converts below account average
|
|
DEFAULT_CAP_MULTIPLE = 3.0 # lost spend cannot exceed 3x daily budget
|
|
MIN_IN_BUDGET_MIN = 60 # below an hour in budget the implied rate is noise
|
|
|
|
W_SHARE, W_EARLY, W_FLAP = 0.55, 0.30, 0.15
|
|
FLAP_CEILING = 12
|
|
|
|
HEALTHY_SHARE = 0.05
|
|
UNDERFUNDED_SHARE = 0.50
|
|
THRASH_EPISODES = 5
|
|
THRASH_SHARE = 0.35
|
|
NOON, NINE_AM, SIX_PM = 720, 540, 1080
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ModelSettings:
|
|
roas: float = 4.33
|
|
roas_source: str = "account_average"
|
|
haircut: float = DEFAULT_ROAS_HAIRCUT
|
|
cap_multiple: float = DEFAULT_CAP_MULTIPLE
|
|
w_share: float = W_SHARE
|
|
w_early: float = W_EARLY
|
|
w_flap: float = W_FLAP
|
|
|
|
|
|
def severity_parts(day: CampaignDay) -> tuple[float, float, float]:
|
|
share = day.oob_share
|
|
if day.first_oob_min is None or day.eligible_min <= 0:
|
|
early = 0.0
|
|
else:
|
|
early = 1.0 - (day.first_oob_min - day.t0) / day.eligible_min
|
|
early = min(1.0, max(0.0, early))
|
|
flap = min(day.episodes_merged, FLAP_CEILING) / FLAP_CEILING
|
|
return share, early, flap
|
|
|
|
|
|
def diagnose(day: CampaignDay) -> str:
|
|
share = day.oob_share
|
|
first = day.first_oob_min
|
|
if day.eligible_min > 0 and day.paused_min / day.eligible_min > 0.5:
|
|
return "Mostly paused"
|
|
if share < HEALTHY_SHARE:
|
|
return "Healthy"
|
|
if share >= UNDERFUNDED_SHARE and first is not None and first < NOON:
|
|
return "Structurally underfunded"
|
|
if first is not None and first < NINE_AM:
|
|
return "Exhausts early"
|
|
if day.episodes_merged >= THRASH_EPISODES and share < THRASH_SHARE:
|
|
return "Pacing thrash"
|
|
if first is not None and first >= SIX_PM:
|
|
return "Evening cap"
|
|
return "Intermittent"
|
|
|
|
|
|
def price_lost_opportunity(day: CampaignDay, s: ModelSettings) -> dict | None:
|
|
"""Project the spend the campaign could not place while capped."""
|
|
budget = day.budget.time_weighted or day.budget.value
|
|
if budget is None or budget <= 0:
|
|
return None
|
|
if day.in_min < MIN_IN_BUDGET_MIN:
|
|
# Too little in-budget time to imply a trustworthy hourly rate.
|
|
return {
|
|
"rate_reliable": False,
|
|
"spend_rate_per_hour": None,
|
|
"lost_spend": None,
|
|
"lost_sales": None,
|
|
"capped": False,
|
|
"budget_used": budget,
|
|
}
|
|
|
|
rate = budget / (day.in_min / 60)
|
|
raw = rate * (day.oob_min / 60)
|
|
cap = s.cap_multiple * budget
|
|
capped = raw > cap
|
|
lost_spend = min(raw, cap)
|
|
return {
|
|
"rate_reliable": True,
|
|
"spend_rate_per_hour": rate,
|
|
"lost_spend": lost_spend,
|
|
"lost_sales": lost_spend * s.roas * s.haircut,
|
|
"capped": capped,
|
|
"budget_used": budget,
|
|
}
|
|
|
|
|
|
def apply(days: list[CampaignDay], settings: ModelSettings) -> None:
|
|
"""Attach severity, diagnosis and lost-opportunity to each campaign-day."""
|
|
for day in days:
|
|
share, early, flap = severity_parts(day)
|
|
day.severity = 100 * (
|
|
settings.w_share * share + settings.w_early * early + settings.w_flap * flap
|
|
)
|
|
day.diagnosis = diagnose(day)
|
|
day.lost = price_lost_opportunity(day, settings)
|
|
if day.diagnosis == "Mostly paused" and day.lost:
|
|
# A paused campaign forgoes nothing to its budget.
|
|
day.lost = {**day.lost, "lost_spend": None, "lost_sales": None,
|
|
"rate_reliable": False}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Totals:
|
|
campaigns: int = 0 # campaign-days: one row per campaign per day scored
|
|
distinct_campaigns: int = 0
|
|
days: int = 0
|
|
oob_hours: float = 0.0
|
|
oob_hours_raw: float = 0.0
|
|
in_hours: float = 0.0
|
|
paused_hours: float = 0.0
|
|
na_hours: float = 0.0
|
|
at_least_1h: int = 0
|
|
over_12h: int = 0
|
|
ended_oob: int = 0
|
|
opened_oob: int = 0
|
|
flapping_3plus: int = 0
|
|
priced: int = 0
|
|
capped: int = 0
|
|
rate_unreliable: int = 0
|
|
lost_spend: float = 0.0
|
|
lost_sales: float = 0.0
|
|
repaired: int = 0
|
|
partial_day: int = 0
|
|
|
|
@property
|
|
def priced_share(self) -> float:
|
|
return self.priced / self.campaigns if self.campaigns else 0.0
|
|
|
|
|
|
def summarize(days: list[CampaignDay]) -> Totals:
|
|
t = Totals(
|
|
campaigns=len(days),
|
|
distinct_campaigns=len({d.campaign for d in days}),
|
|
days=len({d.date_key for d in days}),
|
|
)
|
|
for d in days:
|
|
t.oob_hours += d.oob_hours
|
|
t.oob_hours_raw += d.oob_min_raw / 60
|
|
t.in_hours += d.in_hours
|
|
t.paused_hours += d.paused_hours
|
|
t.na_hours += d.na_min / 60
|
|
t.at_least_1h += d.oob_min >= 60
|
|
t.over_12h += d.oob_min > 720
|
|
t.ended_oob += d.closed_oob
|
|
t.opened_oob += d.opened_oob
|
|
t.flapping_3plus += d.episodes_merged >= 3
|
|
t.repaired += bool(d.chain_breaks)
|
|
t.partial_day += d.confidence == "partial_day"
|
|
if d.budget.source != "unknown":
|
|
t.priced += 1
|
|
if d.lost:
|
|
t.capped += bool(d.lost.get("capped"))
|
|
t.rate_unreliable += not d.lost.get("rate_reliable")
|
|
t.lost_spend += d.lost.get("lost_spend") or 0.0
|
|
t.lost_sales += d.lost.get("lost_sales") or 0.0
|
|
return t
|
|
|
|
|
|
def hourly_starvation(days: list[CampaignDay]) -> list[float]:
|
|
"""Share of scored campaigns out of budget during each hour of the day."""
|
|
if not days:
|
|
return [0.0] * 24
|
|
curve = []
|
|
for h in range(24):
|
|
affected = sum(1 for d in days if d.hourly_oob[h] > 0)
|
|
curve.append(100 * affected / len(days))
|
|
return curve
|