feat(campaigns): enforce budget guardrails automatically
Adds the insights reader, a pure guardrail evaluator, and the sweep that
applies them to live campaigns.
Three things carry the weight here:
Units. Meta reports spend in major units ("12.34") while budgets and
guardrails are in minor units (1234). normalise_row converts, rounding
half up rather than using round(), which rounds halves to even and can
record an exact half-unit of spend as nothing.
Leads. Meta has no leads field; leads live in the actions array, under
several action types depending on whether the lead came from a Facebook
form or a pixel. Cost per lead is computed from spend and leads over the
same window rather than read from cost_per_action_type, so the two can
never disagree.
Order. The campaign is paused on Meta before the local record changes. A
campaign recorded as paused but still delivering is the outcome this
exists to prevent. One campaign's failure never aborts the sweep, so a
rate limit on the third does not leave the fourth unguarded.
Cost rules are skipped until the first lead or click: no leads yet is not
an infinite cost per lead, and pausing for that would kill every campaign
in its first hour.
Deliberately a deterministic loop rather than a maskanx_cron_jobs entry.
That scheduler runs prompts through an agent, and asking a language model
whether a budget has been exceeded would make an arithmetic guarantee
probabilistic. Follows reconcile.py's lifespan-task pattern instead, and
warns every cycle if Meta is unconfigured — a safety system that cannot
run should be loud.
require_approval_for_budget_increase is enforced at the API layer, where
the budget is actually edited: raising it while a campaign awaits approval
returns 409, since it would change what the approver is reviewing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,48 @@ Campaigns that have not reached Meta yet (`draft`, `pending_approval`,
|
||||
The interval defaults to 120 seconds and is set with
|
||||
`MASKANX_CAMPAIGN_RECONCILE_SECONDS`; `0` disables the loop.
|
||||
|
||||
### Guardrails
|
||||
|
||||
Guardrails are the only thing in MaskanX that halts spending without a
|
||||
human. A background sweep reads each **live** campaign's spend from Meta and
|
||||
applies the campaign's own rules:
|
||||
|
||||
| Rule | Window | Action |
|
||||
| --- | --- | --- |
|
||||
| `max_campaign_spend` | lifetime | stop |
|
||||
| `auto_pause_if_spend_reaches` | today | pause |
|
||||
| `max_cost_per_lead` | today | pause |
|
||||
| `max_cost_per_click` | today | pause |
|
||||
|
||||
The most severe breach wins, so a campaign over both its lifetime cap and
|
||||
its cost per lead is stopped rather than merely paused. `stop_loss_enabled`
|
||||
gates only `max_campaign_spend`, the one rule that ends a campaign
|
||||
permanently; turning it off leaves the pause rules working.
|
||||
|
||||
Every amount is in **minor** currency units (paise, cents), matching the
|
||||
budget fields. Cost rules are skipped until the campaign has its first lead
|
||||
or click — no leads yet is not an infinite cost per lead, and pausing for
|
||||
that would kill every campaign in its first hour.
|
||||
|
||||
The campaign is paused **on Meta first**, then recorded locally: a campaign
|
||||
recorded as paused but still delivering is the outcome the whole mechanism
|
||||
exists to prevent. Each action writes an event naming the rule and the
|
||||
observed value, so a campaign that stopped overnight can be explained the
|
||||
next morning, and the Campaigns page tags it.
|
||||
|
||||
Interval: `MASKANX_GUARDRAIL_INTERVAL_SECONDS`, default 900 (15 minutes).
|
||||
`0` disables enforcement, which is logged as a warning at startup — a safety
|
||||
system that is not running should be loud.
|
||||
|
||||
This is a deterministic loop, **not** a `maskanx_cron_jobs` entry. That
|
||||
scheduler runs prompts through an agent, and asking a language model whether
|
||||
a budget has been exceeded would make an arithmetic guarantee probabilistic.
|
||||
|
||||
`require_approval_for_budget_increase` is enforced at the API layer instead:
|
||||
raising a budget on a campaign that is awaiting approval returns `409`,
|
||||
because it would change the figure the approver is reviewing. Reject it back
|
||||
to draft, edit, and resubmit.
|
||||
|
||||
### Live smoke test
|
||||
|
||||
The unit suite runs entirely against a fake transport: it proves the code
|
||||
|
||||
@@ -282,6 +282,27 @@ async def lifespan(app: FastAPI): # pylint: disable=too-many-statements
|
||||
"META_ADS_ACCOUNT_ID not set; campaign reconciliation not started",
|
||||
)
|
||||
|
||||
# --- Guardrail enforcement ---
|
||||
# Started regardless of META_ADS_ACCOUNT_ID: it finds its work by listing
|
||||
# live campaigns, not by scanning an account. A missing Meta token is
|
||||
# reported by the loop each cycle rather than silently skipped here — a
|
||||
# safety system that is not running should be loud.
|
||||
guardrail_task = None
|
||||
from ..campaigns.enforce import enforce_loop, guardrail_interval_seconds
|
||||
|
||||
if guardrail_interval_seconds() > 0:
|
||||
from ..campaigns.repo import CampaignRepository
|
||||
from ..meta.client import MetaClient, access_token_from_env
|
||||
|
||||
def _guardrail_meta_client():
|
||||
return MetaClient(access_token=access_token_from_env())
|
||||
|
||||
guardrail_task = asyncio.create_task(
|
||||
enforce_loop(CampaignRepository, _guardrail_meta_client),
|
||||
name="campaign_guardrails",
|
||||
)
|
||||
app.state.guardrail_task = guardrail_task
|
||||
|
||||
try:
|
||||
if mcp_initial_config is not None:
|
||||
mcp_init_task = _schedule_mcp_initialization(
|
||||
@@ -295,6 +316,8 @@ async def lifespan(app: FastAPI): # pylint: disable=too-many-statements
|
||||
app.state.watchdog.stop()
|
||||
if reconcile_task is not None:
|
||||
reconcile_task.cancel()
|
||||
if guardrail_task is not None:
|
||||
guardrail_task.cancel()
|
||||
# stop order: watchers -> cron -> channels -> mcp -> runner
|
||||
try:
|
||||
await config_watcher.stop()
|
||||
|
||||
@@ -261,6 +261,43 @@ async def list_campaign_events(campaign_id: str) -> list[dict[str, Any]]:
|
||||
return await get_repository().list_events(campaign_id)
|
||||
|
||||
|
||||
def _assert_budget_increase_allowed(
|
||||
campaign: CampaignSpec,
|
||||
new_budget: dict[str, Any],
|
||||
) -> None:
|
||||
"""Refuse to raise the budget out from under a pending approval.
|
||||
|
||||
With `require_approval_for_budget_increase` set, an approver reviewing a
|
||||
campaign must be reviewing the number it will actually spend. Raising it
|
||||
while the campaign sits in `pending_approval` would leave the approval
|
||||
attached to a figure nobody agreed to.
|
||||
|
||||
Only increases are blocked; lowering a budget needs no re-approval, and
|
||||
a campaign still in `draft` has no pending review to invalidate. Reject
|
||||
it back to draft, change the budget, and submit again.
|
||||
"""
|
||||
if not campaign.guardrails.get("require_approval_for_budget_increase"):
|
||||
return
|
||||
if campaign.status != "pending_approval":
|
||||
return
|
||||
|
||||
for field in ("daily_budget", "lifetime_budget"):
|
||||
old = campaign.budget.get(field)
|
||||
new = new_budget.get(field)
|
||||
if old is None or new is None:
|
||||
continue
|
||||
if new > old:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"This campaign requires approval for a budget increase, "
|
||||
f"and it is awaiting approval. Raising {field} from {old} "
|
||||
f"to {new} would change what is being reviewed. Reject it "
|
||||
f"back to draft first, then edit and resubmit."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{campaign_id}", response_model=CampaignSpec)
|
||||
async def update_campaign(
|
||||
campaign_id: str,
|
||||
@@ -276,6 +313,7 @@ async def update_campaign(
|
||||
effective_budget = updates.get("budget", campaign.budget)
|
||||
effective_guardrails = updates.get("guardrails", campaign.guardrails)
|
||||
effective_account = updates.get("ad_account_id", campaign.ad_account_id)
|
||||
_assert_budget_increase_allowed(campaign, effective_budget)
|
||||
await _validate_or_422(effective_budget, effective_guardrails, effective_account)
|
||||
for field, value in updates.items():
|
||||
setattr(campaign, field, value)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Apply guardrails to live campaigns, on a schedule.
|
||||
|
||||
This is the only part of MaskanX that halts spending without a human, so
|
||||
its failure modes matter more than its features:
|
||||
|
||||
- It acts **only** on campaigns that are `live`. Nothing else can be
|
||||
spending, and acting on anything else would fight the operator.
|
||||
- It pauses on Meta **before** recording the change locally. A campaign
|
||||
recorded as paused but still delivering is the outcome this whole module
|
||||
exists to prevent.
|
||||
- One campaign's failure never stops the others being checked. A rate limit
|
||||
on campaign three must not leave campaigns four and five unguarded.
|
||||
- Every action writes an event naming the rule and the observed value, so a
|
||||
campaign that stopped overnight can be explained the next morning.
|
||||
|
||||
Deliberately not an LLM cron job. MaskanX's `maskanx_cron_jobs` scheduler
|
||||
runs prompts through an agent; asking a language model whether a budget has
|
||||
been exceeded would make an arithmetic guarantee probabilistic. This
|
||||
follows `reconcile.py` instead: a deterministic loop wired into the app
|
||||
lifespan.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .guardrails import GuardrailBreach, evaluate
|
||||
from .state import next_status
|
||||
from ..meta.client import MetaNotConfiguredError
|
||||
from ..meta.insights import total_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INTERVAL_ENV = "MASKANX_GUARDRAIL_INTERVAL_SECONDS"
|
||||
DEFAULT_INTERVAL_SECONDS = 900.0 # 15 minutes, per the design spec.
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnforcementResult:
|
||||
paused: list[str] = field(default_factory=list)
|
||||
stopped: list[str] = field(default_factory=list)
|
||||
checked: int = 0
|
||||
failed: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def acted(self) -> bool:
|
||||
return bool(self.paused or self.stopped)
|
||||
|
||||
|
||||
def guardrail_interval_seconds() -> float:
|
||||
"""Return the check interval; 0 or less disables enforcement."""
|
||||
raw = os.environ.get(INTERVAL_ENV)
|
||||
if raw is None:
|
||||
return DEFAULT_INTERVAL_SECONDS
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"%s is not a number (%r); using the default of %.0fs",
|
||||
INTERVAL_ENV,
|
||||
raw,
|
||||
DEFAULT_INTERVAL_SECONDS,
|
||||
)
|
||||
return DEFAULT_INTERVAL_SECONDS
|
||||
|
||||
|
||||
async def observed_spend(meta, campaign) -> dict[str, int | None]:
|
||||
"""Read today's and lifetime spend for one campaign.
|
||||
|
||||
Two calls rather than one: a guardrail on today's spend and a stop-loss
|
||||
on total spend are different windows, and Meta will not return both in
|
||||
a single row.
|
||||
|
||||
A campaign with no delivery yet returns zeroes rather than raising, so
|
||||
the caller can evaluate it like any other.
|
||||
"""
|
||||
today_rows = await meta.get_insights(
|
||||
campaign.meta_campaign_id, date_preset="today",
|
||||
)
|
||||
lifetime_rows = await meta.get_insights(
|
||||
campaign.meta_campaign_id, date_preset="maximum",
|
||||
)
|
||||
|
||||
today = total_metrics(today_rows)
|
||||
lifetime = total_metrics(lifetime_rows)
|
||||
return {
|
||||
"today_spend": today["spend"],
|
||||
"lifetime_spend": lifetime["spend"],
|
||||
"cost_per_lead": today["cost_per_lead"],
|
||||
"cost_per_click": today["cost_per_click"],
|
||||
}
|
||||
|
||||
|
||||
async def _apply(repo, meta, campaign, breach: GuardrailBreach) -> None:
|
||||
"""Halt the campaign on Meta, then record it. Order matters."""
|
||||
await meta.update_object_status(campaign.meta_campaign_id, "PAUSED")
|
||||
|
||||
campaign.status = next_status(campaign.status, breach.action)
|
||||
campaign.advanced["last_guardrail_trip"] = {
|
||||
"rule": breach.rule,
|
||||
"action": breach.action,
|
||||
"limit": breach.limit,
|
||||
"observed": breach.observed,
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await repo.update_campaign_with_event(
|
||||
campaign,
|
||||
event_type=f"campaign.guardrail_{breach.action}",
|
||||
actor="guardrail",
|
||||
reason=breach.reason,
|
||||
payload={
|
||||
"rule": breach.rule,
|
||||
"limit": breach.limit,
|
||||
"observed": breach.observed,
|
||||
},
|
||||
)
|
||||
logger.warning(
|
||||
"Guardrail %s campaign %s: %s", breach.action, campaign.id, breach.reason,
|
||||
)
|
||||
|
||||
|
||||
async def enforce_once(repo, meta) -> EnforcementResult:
|
||||
"""Check every live campaign once. Returns what was acted on."""
|
||||
result = EnforcementResult()
|
||||
|
||||
campaigns = await repo.list_campaigns(status="live")
|
||||
for campaign in campaigns:
|
||||
if not campaign.meta_campaign_id:
|
||||
# Live without a Meta object should not happen, but if it does
|
||||
# there is nothing to read spend from and nothing to pause.
|
||||
continue
|
||||
try:
|
||||
result.checked += 1
|
||||
spend = await observed_spend(meta, campaign)
|
||||
breach = evaluate(campaign.guardrails or {}, **spend)
|
||||
if breach is None:
|
||||
continue
|
||||
await _apply(repo, meta, campaign, breach)
|
||||
if breach.action == "stop":
|
||||
result.stopped.append(campaign.id)
|
||||
else:
|
||||
result.paused.append(campaign.id)
|
||||
except Exception as exc:
|
||||
# One campaign failing must not leave the rest unguarded, so
|
||||
# this catches broadly and continues rather than aborting the
|
||||
# sweep. The campaign keeps running, which is why the failure
|
||||
# is logged at warning rather than swallowed.
|
||||
result.failed.append(campaign.id)
|
||||
logger.warning(
|
||||
"Guardrail check failed for campaign %s: %s", campaign.id, exc,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def enforce_loop(repo_factory, meta_factory) -> None:
|
||||
"""Enforce forever. Never raises: a bad cycle is logged and retried."""
|
||||
interval = guardrail_interval_seconds()
|
||||
if interval <= 0:
|
||||
logger.warning(
|
||||
"Guardrail enforcement is DISABLED (%s=0). Campaigns will not be "
|
||||
"paused automatically when they exceed their limits.",
|
||||
INTERVAL_ENV,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("Guardrail enforcement started, every %.0fs", interval)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
result = await enforce_once(repo_factory(), meta_factory())
|
||||
if result.acted:
|
||||
logger.warning(
|
||||
"Guardrails acted: %d paused, %d stopped (of %d checked)",
|
||||
len(result.paused),
|
||||
len(result.stopped),
|
||||
result.checked,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Guardrail enforcement stopped")
|
||||
raise
|
||||
except MetaNotConfiguredError:
|
||||
# Warned every cycle on purpose. A safety system that cannot run
|
||||
# should be loud, not quietly absent.
|
||||
logger.warning(
|
||||
"Guardrails cannot run: Meta is not configured. Set "
|
||||
"META_ADS_ACCESS_TOKEN in Settings > Environments. Campaigns "
|
||||
"will not be paused automatically until then.",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Guardrail enforcement cycle failed")
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Decide whether a campaign's own rules say it should stop spending.
|
||||
|
||||
Pure evaluation: no I/O, no Meta calls, no database. `evaluate` takes the
|
||||
guardrails an operator set and the spend actually observed, and returns the
|
||||
action to take. Applying that action is `enforce.py`'s job.
|
||||
|
||||
Keeping the decision separate from the acting is what makes this testable
|
||||
without a fake Meta, and it is worth the split because these rules are the
|
||||
last automatic defence against a campaign spending more than intended.
|
||||
|
||||
Every amount here is in **minor** currency units (paise, cents), matching
|
||||
`meta.insights.normalise_row` and Graph's budget fields. Mixing major and
|
||||
minor units would make a limit either 100x too loose or 100x too tight.
|
||||
|
||||
Two rules deliberately do NOT live here:
|
||||
|
||||
- `require_approval_for_budget_increase` is enforced at the API layer, when
|
||||
the budget is edited. There is nothing for a periodic check to do about a
|
||||
budget that has already been raised.
|
||||
- `daily_budget_limit` / `lifetime_budget_limit` are validated when the
|
||||
budget is set (`validation.py`). They cap what may be configured; these
|
||||
rules cap what may actually be spent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuardrailBreach:
|
||||
"""One rule that has been broken, and what to do about it."""
|
||||
|
||||
rule: str
|
||||
action: str # "pause" or "stop"
|
||||
limit: int
|
||||
observed: int
|
||||
|
||||
@property
|
||||
def reason(self) -> str:
|
||||
return (
|
||||
f"{self.rule}: observed {self.observed} against a limit of "
|
||||
f"{self.limit} (minor currency units)"
|
||||
)
|
||||
|
||||
|
||||
def _limit(guardrails: dict[str, Any], key: str) -> int | None:
|
||||
"""Read a numeric limit, treating anything unusable as 'not set'.
|
||||
|
||||
A limit of 0 is honoured as a real limit, not read as absent: someone
|
||||
who sets a cap of zero means "do not spend", and `if not value` would
|
||||
silently discard that.
|
||||
"""
|
||||
value = guardrails.get(key)
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def evaluate(
|
||||
guardrails: dict[str, Any],
|
||||
*,
|
||||
today_spend: int,
|
||||
lifetime_spend: int,
|
||||
cost_per_lead: int | None = None,
|
||||
cost_per_click: int | None = None,
|
||||
) -> GuardrailBreach | None:
|
||||
"""Return the breach that should act on this campaign, or None.
|
||||
|
||||
Rules are checked most-severe first and the first match wins, so a
|
||||
campaign that has blown both its lifetime cap and its cost per lead is
|
||||
stopped rather than merely paused. Returning one breach rather than a
|
||||
list keeps the caller from having to decide which of several actions to
|
||||
apply.
|
||||
|
||||
`stop_loss_enabled` gates only `max_campaign_spend`, the one rule that
|
||||
ends a campaign permanently. Turning it off leaves the pause rules
|
||||
working, which is the point of having it as a separate switch.
|
||||
"""
|
||||
lifetime_cap = _limit(guardrails, "max_campaign_spend")
|
||||
if (
|
||||
lifetime_cap is not None
|
||||
and guardrails.get("stop_loss_enabled")
|
||||
and lifetime_spend >= lifetime_cap
|
||||
):
|
||||
return GuardrailBreach(
|
||||
rule="max_campaign_spend",
|
||||
action="stop",
|
||||
limit=lifetime_cap,
|
||||
observed=lifetime_spend,
|
||||
)
|
||||
|
||||
daily_cap = _limit(guardrails, "auto_pause_if_spend_reaches")
|
||||
if daily_cap is not None and today_spend >= daily_cap:
|
||||
return GuardrailBreach(
|
||||
rule="auto_pause_if_spend_reaches",
|
||||
action="pause",
|
||||
limit=daily_cap,
|
||||
observed=today_spend,
|
||||
)
|
||||
|
||||
# Cost rules are skipped while the observed value is None: a campaign
|
||||
# with no leads yet has no cost per lead, and pausing it for that would
|
||||
# kill every campaign in its first hour.
|
||||
cpl_cap = _limit(guardrails, "max_cost_per_lead")
|
||||
if cpl_cap is not None and cost_per_lead is not None and cost_per_lead > cpl_cap:
|
||||
return GuardrailBreach(
|
||||
rule="max_cost_per_lead",
|
||||
action="pause",
|
||||
limit=cpl_cap,
|
||||
observed=cost_per_lead,
|
||||
)
|
||||
|
||||
cpc_cap = _limit(guardrails, "max_cost_per_click")
|
||||
if cpc_cap is not None and cost_per_click is not None and cost_per_click > cpc_cap:
|
||||
return GuardrailBreach(
|
||||
rule="max_cost_per_click",
|
||||
action="pause",
|
||||
limit=cpc_cap,
|
||||
observed=cost_per_click,
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -52,6 +52,26 @@ CREATE_STATUS = "PAUSED"
|
||||
|
||||
LIST_FIELDS = "id,name,status,effective_status,created_time"
|
||||
|
||||
# Metrics pulled for every insights read. `actions` and `cost_per_action_type`
|
||||
# are where leads live: Meta reports them as action rows, not as a top-level
|
||||
# field, so anything lead-related has to be dug out of those arrays.
|
||||
INSIGHT_FIELDS = ",".join(
|
||||
(
|
||||
"spend",
|
||||
"impressions",
|
||||
"reach",
|
||||
"frequency",
|
||||
"clicks",
|
||||
"ctr",
|
||||
"cpc",
|
||||
"cpm",
|
||||
"actions",
|
||||
"cost_per_action_type",
|
||||
"date_start",
|
||||
"date_stop",
|
||||
),
|
||||
)
|
||||
|
||||
# Path suffixes that create a campaign/ad set/ad. _post refuses any of these
|
||||
# with a non-PAUSED status as defence in depth, in case a future caller
|
||||
# bypasses the typed create_* guards and calls _post directly.
|
||||
@@ -203,6 +223,46 @@ class MetaClient:
|
||||
{"fields": AD_ACCOUNT_FIELDS},
|
||||
)
|
||||
|
||||
async def get_insights(
|
||||
self,
|
||||
object_id: str,
|
||||
*,
|
||||
date_preset: str | None = None,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
level: str | None = None,
|
||||
breakdowns: list[str] | None = None,
|
||||
time_increment: int | str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return insight rows for a campaign, ad set, ad or ad account.
|
||||
|
||||
Pass either `date_preset` (e.g. "today", "yesterday") or an explicit
|
||||
`since`/`until` pair of YYYY-MM-DD dates; passing neither gives
|
||||
Meta's own default window, which is not what a caller measuring
|
||||
spend wants, so one of the two should always be supplied.
|
||||
|
||||
Returns the raw rows. Meta reports every metric as a string and
|
||||
reports leads inside the `actions` array rather than as a field of
|
||||
its own, so callers should go through `adclaw.meta.insights` rather
|
||||
than reading these dicts directly.
|
||||
|
||||
An object with no delivery in the window returns `[]`, not zeroes.
|
||||
"""
|
||||
params: dict[str, Any] = {"fields": INSIGHT_FIELDS}
|
||||
if date_preset:
|
||||
params["date_preset"] = date_preset
|
||||
if since and until:
|
||||
params["time_range"] = json.dumps({"since": since, "until": until})
|
||||
if level:
|
||||
params["level"] = level
|
||||
if breakdowns:
|
||||
params["breakdowns"] = ",".join(breakdowns)
|
||||
if time_increment is not None:
|
||||
params["time_increment"] = time_increment
|
||||
|
||||
result = await self._get(f"/{object_id}/insights", params)
|
||||
return result.get("data") or []
|
||||
|
||||
async def generate_previews(
|
||||
self,
|
||||
ad_account_id: str,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Turn Meta's insight rows into numbers that can be compared and stored.
|
||||
|
||||
Two things make raw rows unusable as they arrive:
|
||||
|
||||
1. **Every metric is a string.** Meta returns `"spend": "1234.56"`, not a
|
||||
number. Comparing that to a guardrail limit with `>` would compare a
|
||||
string to an int and raise, or worse, compare lexically.
|
||||
|
||||
2. **Leads are not a field.** They live inside the `actions` array as
|
||||
`{"action_type": "lead", "value": "3"}`, alongside every other action
|
||||
type. Cost per lead is likewise inside `cost_per_action_type`.
|
||||
|
||||
Spend is also in **major** currency units here (rupees, dollars), while
|
||||
budgets and guardrails elsewhere in MaskanX are in **minor** units (paise,
|
||||
cents) to match Graph's budget fields. `normalise_row` converts spend to
|
||||
minor units so a guardrail limit and an observed spend are the same kind of
|
||||
number. This is the single most important thing in this module: a factor of
|
||||
100 in the wrong direction either never trips a guardrail or trips every
|
||||
one of them.
|
||||
|
||||
These functions do no I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Meta names the same outcome differently depending on how the lead arrived.
|
||||
# An on-Facebook lead form reports `lead`; a website conversion reports
|
||||
# `offsite_conversion.fb_pixel_lead`. Both are leads for our purposes.
|
||||
LEAD_ACTION_TYPES = (
|
||||
"lead",
|
||||
"onsite_conversion.lead_grouped",
|
||||
"offsite_conversion.fb_pixel_lead",
|
||||
"leadgen_grouped",
|
||||
)
|
||||
|
||||
LINK_CLICK_ACTION_TYPES = ("link_click",)
|
||||
|
||||
PURCHASE_ACTION_TYPES = (
|
||||
"purchase",
|
||||
"offsite_conversion.fb_pixel_purchase",
|
||||
"omni_purchase",
|
||||
)
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
"""Parse one of Meta's stringified metrics. Anything unusable is 0.0."""
|
||||
if value is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _to_minor_units(major: float) -> int:
|
||||
"""Convert a major-unit amount to minor units, rounding half **up**.
|
||||
|
||||
Not `round()`: Python rounds halves to even, so `round(0.5)` is 0. For
|
||||
money that means an exact half-unit of spend can be recorded as nothing,
|
||||
and under-reported spend is what lets a campaign slip past a stop-loss.
|
||||
Adding 0.5 before truncating always rounds a tie upward instead.
|
||||
|
||||
Amounts here are spend and costs, which are never negative; the
|
||||
`max(0.0, ...)` makes that explicit rather than silently mis-rounding
|
||||
if Meta ever returns one.
|
||||
"""
|
||||
return int(max(0.0, major) * 100 + 0.5)
|
||||
|
||||
|
||||
def _divide(spend: int, count: int) -> int | None:
|
||||
"""Cost per unit, or None when there are no units yet.
|
||||
|
||||
None is not zero and not infinity: a campaign that has not converted
|
||||
anyone yet has no cost per lead to speak of, and a guardrail must not
|
||||
trip on it. Rounds half up, like every other money figure here.
|
||||
"""
|
||||
if not count:
|
||||
return None
|
||||
return int(spend / count + 0.5)
|
||||
|
||||
|
||||
def action_value(rows: Any, action_types: tuple[str, ...]) -> float:
|
||||
"""Sum the values of matching action rows.
|
||||
|
||||
Meta can report the same outcome under more than one action type in one
|
||||
row, so this sums rather than taking the first match.
|
||||
"""
|
||||
if not isinstance(rows, list):
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if row.get("action_type") in action_types:
|
||||
total += _number(row.get("value"))
|
||||
return total
|
||||
|
||||
|
||||
def normalise_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return one insight row as comparable numbers.
|
||||
|
||||
`spend` and every cost are in **minor** currency units, matching budgets
|
||||
and guardrails. Derived costs are computed here rather than read from
|
||||
Meta's `cost_per_action_type`, so that spend and cost per lead can never
|
||||
disagree about the same window.
|
||||
"""
|
||||
spend_major = _number(row.get("spend"))
|
||||
spend = _to_minor_units(spend_major)
|
||||
|
||||
impressions = int(_number(row.get("impressions")))
|
||||
clicks = int(_number(row.get("clicks")))
|
||||
actions = row.get("actions")
|
||||
|
||||
leads = int(action_value(actions, LEAD_ACTION_TYPES))
|
||||
link_clicks = int(action_value(actions, LINK_CLICK_ACTION_TYPES))
|
||||
purchases = int(action_value(actions, PURCHASE_ACTION_TYPES))
|
||||
|
||||
return {
|
||||
"spend": spend,
|
||||
"impressions": impressions,
|
||||
"reach": int(_number(row.get("reach"))),
|
||||
"frequency": _number(row.get("frequency")),
|
||||
"clicks": clicks,
|
||||
"link_clicks": link_clicks,
|
||||
"ctr": _number(row.get("ctr")),
|
||||
"cpc": _to_minor_units(_number(row.get("cpc"))),
|
||||
"cpm": _to_minor_units(_number(row.get("cpm"))),
|
||||
"leads": leads,
|
||||
"purchases": purchases,
|
||||
"cost_per_lead": _divide(spend, leads),
|
||||
"cost_per_click": _divide(spend, clicks),
|
||||
"date_start": row.get("date_start"),
|
||||
"date_stop": row.get("date_stop"),
|
||||
}
|
||||
|
||||
|
||||
def total_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Aggregate several insight rows into one, as if they were one window.
|
||||
|
||||
Sums are summed; costs are recomputed from the totals rather than
|
||||
averaged, since an average of per-day costs is not the cost over the
|
||||
period.
|
||||
|
||||
An empty list gives a zeroed row rather than `{}`, so a caller can
|
||||
compare against it without checking for emptiness first — a campaign
|
||||
with no delivery has spent nothing, which is a fact, not a gap.
|
||||
"""
|
||||
totals = {
|
||||
"spend": 0,
|
||||
"impressions": 0,
|
||||
"reach": 0,
|
||||
"clicks": 0,
|
||||
"link_clicks": 0,
|
||||
"leads": 0,
|
||||
"purchases": 0,
|
||||
}
|
||||
for row in rows:
|
||||
normalised = normalise_row(row)
|
||||
for key in totals:
|
||||
totals[key] += normalised[key]
|
||||
|
||||
spend = totals["spend"]
|
||||
totals["cost_per_lead"] = _divide(spend, totals["leads"])
|
||||
totals["cost_per_click"] = _divide(spend, totals["clicks"])
|
||||
totals["ctr"] = (
|
||||
(totals["clicks"] / totals["impressions"] * 100)
|
||||
if totals["impressions"]
|
||||
else 0.0
|
||||
)
|
||||
return totals
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""require_approval_for_budget_increase.
|
||||
|
||||
The hazard is narrow but real: an approver signs off on a daily budget, and
|
||||
the number is raised before the campaign launches. The approval then names
|
||||
a figure nobody agreed to.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from adclaw.app.routers import campaigns as campaigns_router
|
||||
from adclaw.campaigns.models import CampaignSpec
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self):
|
||||
self.items: dict[str, CampaignSpec] = {}
|
||||
self.events: list[dict] = []
|
||||
|
||||
async def get_campaign(self, campaign_id):
|
||||
return self.items.get(campaign_id)
|
||||
|
||||
async def update_campaign(self, spec):
|
||||
self.items[spec.id] = spec
|
||||
return spec
|
||||
|
||||
async def add_event(self, campaign_id, event_type, actor=None,
|
||||
reason=None, payload=None):
|
||||
self.events.append({"campaign_id": campaign_id, "event_type": event_type})
|
||||
|
||||
|
||||
class FakeMeta:
|
||||
async def get_ad_account(self, ad_account_id):
|
||||
return {"min_daily_budget": 1000, "currency": "INR"}
|
||||
|
||||
|
||||
def _campaign(**overrides) -> CampaignSpec:
|
||||
data = {
|
||||
"id": "camp_1",
|
||||
"name": "Q3 lead gen",
|
||||
"status": "pending_approval",
|
||||
"ad_account_id": "act_1",
|
||||
"budget": {"daily_budget": 50000},
|
||||
"guardrails": {"require_approval_for_budget_increase": True},
|
||||
}
|
||||
data.update(overrides)
|
||||
return CampaignSpec(**data)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
repo = FakeRepo()
|
||||
monkeypatch.setattr(campaigns_router, "get_repository", lambda: repo)
|
||||
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: FakeMeta())
|
||||
app = FastAPI()
|
||||
app.include_router(campaigns_router.router, prefix="/api")
|
||||
return TestClient(app), repo
|
||||
|
||||
|
||||
def _put(client, **budget):
|
||||
return client.put(
|
||||
"/api/campaigns/camp_1",
|
||||
json={"name": "Q3 lead gen", "budget": budget},
|
||||
)
|
||||
|
||||
|
||||
def test_raising_the_budget_while_awaiting_approval_is_refused(env):
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign()
|
||||
|
||||
response = _put(client, daily_budget=90000)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "awaiting approval" in response.json()["detail"]
|
||||
assert repo.items["camp_1"].budget["daily_budget"] == 50000
|
||||
|
||||
|
||||
def test_the_message_names_both_figures(env):
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign()
|
||||
|
||||
detail = _put(client, daily_budget=90000).json()["detail"]
|
||||
|
||||
assert "50000" in detail and "90000" in detail
|
||||
|
||||
|
||||
def test_lowering_the_budget_is_allowed(env):
|
||||
"""A smaller number needs no re-approval."""
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign()
|
||||
|
||||
response = _put(client, daily_budget=20000)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert repo.items["camp_1"].budget["daily_budget"] == 20000
|
||||
|
||||
|
||||
def test_an_unchanged_budget_is_allowed(env):
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign()
|
||||
|
||||
assert _put(client, daily_budget=50000).status_code == 200
|
||||
|
||||
|
||||
def test_a_draft_has_no_pending_review_to_invalidate(env):
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign(status="draft")
|
||||
|
||||
response = _put(client, daily_budget=90000)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert repo.items["camp_1"].budget["daily_budget"] == 90000
|
||||
|
||||
|
||||
def test_without_the_flag_the_budget_can_be_raised(env):
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign(guardrails={})
|
||||
|
||||
assert _put(client, daily_budget=90000).status_code == 200
|
||||
|
||||
|
||||
def test_the_lifetime_budget_is_gated_too(env):
|
||||
client, repo = env
|
||||
repo.items["camp_1"] = _campaign(budget={"lifetime_budget": 500000})
|
||||
|
||||
response = _put(client, lifetime_budget=900000)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "lifetime_budget" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_a_budget_field_absent_before_is_not_treated_as_an_increase():
|
||||
"""Setting a budget field for the first time is not a raise.
|
||||
|
||||
Checked directly rather than through PUT: setting both a daily and a
|
||||
lifetime budget is separately rejected by validation, which would mask
|
||||
what this is testing.
|
||||
"""
|
||||
campaign = _campaign(budget={"daily_budget": 50000})
|
||||
|
||||
# No exception: lifetime_budget was not previously set.
|
||||
campaigns_router._assert_budget_increase_allowed(
|
||||
campaign, {"daily_budget": 50000, "lifetime_budget": 900000},
|
||||
)
|
||||
|
||||
|
||||
def test_dropping_a_budget_field_from_the_update_is_not_an_increase():
|
||||
"""A partial update that omits the budget must not read as a change."""
|
||||
campaign = _campaign(budget={"daily_budget": 50000})
|
||||
|
||||
campaigns_router._assert_budget_increase_allowed(campaign, {})
|
||||
@@ -0,0 +1,278 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The enforcement sweep: fetching spend, deciding, and acting on Meta.
|
||||
|
||||
The properties worth protecting are the failure modes, not the happy path:
|
||||
Meta is paused before the local record changes, one campaign's failure does
|
||||
not leave the others unguarded, and nothing that is not live is touched.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from adclaw.campaigns.enforce import (
|
||||
DEFAULT_INTERVAL_SECONDS,
|
||||
enforce_once,
|
||||
guardrail_interval_seconds,
|
||||
observed_spend,
|
||||
)
|
||||
from adclaw.campaigns.models import CampaignSpec
|
||||
from adclaw.meta.client import MetaError
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, campaigns=()):
|
||||
self.items = list(campaigns)
|
||||
self.events: list[dict] = []
|
||||
# Records the campaign status at the moment each event was written,
|
||||
# so a test can prove Meta was called first.
|
||||
self.saved_status: list[str] = []
|
||||
|
||||
async def list_campaigns(self, company_id=None, status=None):
|
||||
if status is None:
|
||||
return list(self.items)
|
||||
return [c for c in self.items if c.status == status]
|
||||
|
||||
async def update_campaign_with_event(
|
||||
self, spec, event_type, actor=None, reason=None, payload=None,
|
||||
):
|
||||
self.events.append({
|
||||
"event_type": event_type,
|
||||
"actor": actor,
|
||||
"reason": reason,
|
||||
"payload": payload,
|
||||
})
|
||||
self.saved_status.append(spec.status)
|
||||
return spec
|
||||
|
||||
|
||||
class FakeMeta:
|
||||
def __init__(self, today=None, lifetime=None):
|
||||
self.today = today if today is not None else []
|
||||
self.lifetime = lifetime if lifetime is not None else []
|
||||
self.status_calls: list[tuple[str, str]] = []
|
||||
self.insight_calls: list[tuple[str, str]] = []
|
||||
self.fail_insights_for: set[str] = set()
|
||||
|
||||
async def get_insights(self, object_id, *, date_preset=None, **kwargs):
|
||||
if object_id in self.fail_insights_for:
|
||||
raise MetaError("Rate limited", code=17)
|
||||
self.insight_calls.append((object_id, date_preset))
|
||||
return self.today if date_preset == "today" else self.lifetime
|
||||
|
||||
async def update_object_status(self, object_id, status):
|
||||
self.status_calls.append((object_id, status))
|
||||
|
||||
|
||||
def _campaign(**overrides) -> CampaignSpec:
|
||||
data = {
|
||||
"id": "camp_1",
|
||||
"name": "Q3 lead gen",
|
||||
"status": "live",
|
||||
"ad_account_id": "act_1",
|
||||
"meta_campaign_id": "meta_camp_1",
|
||||
"budget": {"daily_budget": 50000},
|
||||
"guardrails": {
|
||||
"auto_pause_if_spend_reaches": 45000,
|
||||
"stop_loss_enabled": True,
|
||||
},
|
||||
}
|
||||
data.update(overrides)
|
||||
return CampaignSpec(**data)
|
||||
|
||||
|
||||
# --- interval ---
|
||||
|
||||
|
||||
def test_interval_defaults_to_fifteen_minutes(monkeypatch):
|
||||
monkeypatch.delenv("MASKANX_GUARDRAIL_INTERVAL_SECONDS", raising=False)
|
||||
|
||||
assert guardrail_interval_seconds() == DEFAULT_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def test_a_nonsense_interval_falls_back_to_the_default(monkeypatch):
|
||||
"""A typo must not silently disable a safety system."""
|
||||
monkeypatch.setenv("MASKANX_GUARDRAIL_INTERVAL_SECONDS", "every 5 mins")
|
||||
|
||||
assert guardrail_interval_seconds() == DEFAULT_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def test_zero_disables_enforcement(monkeypatch):
|
||||
monkeypatch.setenv("MASKANX_GUARDRAIL_INTERVAL_SECONDS", "0")
|
||||
|
||||
assert guardrail_interval_seconds() == 0
|
||||
|
||||
|
||||
# --- reading spend ---
|
||||
|
||||
|
||||
async def test_observed_spend_reads_today_and_lifetime_separately():
|
||||
meta = FakeMeta(
|
||||
today=[{"spend": "460.00", "clicks": "20"}],
|
||||
lifetime=[{"spend": "5000.00"}],
|
||||
)
|
||||
|
||||
spend = await observed_spend(meta, _campaign())
|
||||
|
||||
assert spend["today_spend"] == 46000
|
||||
assert spend["lifetime_spend"] == 500000
|
||||
assert meta.insight_calls == [
|
||||
("meta_camp_1", "today"),
|
||||
("meta_camp_1", "maximum"),
|
||||
]
|
||||
|
||||
|
||||
async def test_a_campaign_with_no_delivery_reads_as_zero():
|
||||
spend = await observed_spend(FakeMeta(), _campaign())
|
||||
|
||||
assert spend["today_spend"] == 0
|
||||
assert spend["lifetime_spend"] == 0
|
||||
assert spend["cost_per_lead"] is None
|
||||
|
||||
|
||||
# --- the sweep ---
|
||||
|
||||
|
||||
async def test_a_campaign_over_its_daily_limit_is_paused_on_meta():
|
||||
repo = FakeRepo([_campaign()])
|
||||
meta = FakeMeta(today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}])
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.paused == ["camp_1"]
|
||||
assert meta.status_calls == [("meta_camp_1", "PAUSED")]
|
||||
assert repo.items[0].status == "paused"
|
||||
|
||||
|
||||
async def test_the_event_names_the_rule_and_the_observed_value():
|
||||
"""A campaign that stopped overnight has to be explainable."""
|
||||
repo = FakeRepo([_campaign()])
|
||||
meta = FakeMeta(today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}])
|
||||
|
||||
await enforce_once(repo, meta)
|
||||
|
||||
event = repo.events[-1]
|
||||
assert event["event_type"] == "campaign.guardrail_pause"
|
||||
assert event["actor"] == "guardrail"
|
||||
assert event["payload"]["rule"] == "auto_pause_if_spend_reaches"
|
||||
assert event["payload"]["observed"] == 46000
|
||||
assert event["payload"]["limit"] == 45000
|
||||
|
||||
|
||||
async def test_meta_is_paused_before_the_local_record_is_written():
|
||||
"""Recorded-as-paused-but-still-delivering is the outcome to avoid."""
|
||||
repo = FakeRepo([_campaign()])
|
||||
meta = FakeMeta(today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}])
|
||||
|
||||
class OrderCheckingMeta(FakeMeta):
|
||||
async def update_object_status(self, object_id, status):
|
||||
# No event may have been written yet.
|
||||
assert repo.events == []
|
||||
await FakeMeta.update_object_status(self, object_id, status)
|
||||
|
||||
meta = OrderCheckingMeta(
|
||||
today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}],
|
||||
)
|
||||
|
||||
await enforce_once(repo, meta)
|
||||
|
||||
assert meta.status_calls == [("meta_camp_1", "PAUSED")]
|
||||
assert repo.events
|
||||
|
||||
|
||||
async def test_a_campaign_within_its_limits_is_left_alone():
|
||||
repo = FakeRepo([_campaign()])
|
||||
meta = FakeMeta(today=[{"spend": "100.00"}], lifetime=[{"spend": "100.00"}])
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.checked == 1
|
||||
assert not result.acted
|
||||
assert meta.status_calls == []
|
||||
assert repo.events == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["draft", "pending_approval", "approved", "synced", "paused", "stopped"],
|
||||
)
|
||||
async def test_only_live_campaigns_are_checked(status):
|
||||
"""Nothing else can be spending, and acting would fight the operator."""
|
||||
repo = FakeRepo([_campaign(status=status)])
|
||||
meta = FakeMeta(today=[{"spend": "9999.00"}], lifetime=[{"spend": "9999.00"}])
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.checked == 0
|
||||
assert meta.status_calls == []
|
||||
|
||||
|
||||
async def test_a_live_campaign_without_a_meta_object_is_skipped():
|
||||
repo = FakeRepo([_campaign(meta_campaign_id=None)])
|
||||
meta = FakeMeta(today=[{"spend": "9999.00"}])
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.checked == 0
|
||||
assert meta.status_calls == []
|
||||
|
||||
|
||||
async def test_one_campaign_failing_does_not_leave_the_rest_unguarded():
|
||||
"""A rate limit on the first must not skip the second."""
|
||||
repo = FakeRepo([
|
||||
_campaign(id="camp_1", meta_campaign_id="meta_1"),
|
||||
_campaign(id="camp_2", meta_campaign_id="meta_2"),
|
||||
])
|
||||
meta = FakeMeta(today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}])
|
||||
meta.fail_insights_for = {"meta_1"}
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.failed == ["camp_1"]
|
||||
assert result.paused == ["camp_2"]
|
||||
assert ("meta_2", "PAUSED") in meta.status_calls
|
||||
|
||||
|
||||
async def test_a_failed_pause_on_meta_leaves_the_campaign_live():
|
||||
"""Better to keep showing it as live than to lose track of real spend."""
|
||||
repo = FakeRepo([_campaign()])
|
||||
|
||||
class RefusingMeta(FakeMeta):
|
||||
async def update_object_status(self, object_id, status):
|
||||
raise MetaError("Permission denied", code=200)
|
||||
|
||||
meta = RefusingMeta(
|
||||
today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}],
|
||||
)
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.failed == ["camp_1"]
|
||||
assert not result.acted
|
||||
assert repo.items[0].status == "live"
|
||||
assert repo.events == []
|
||||
|
||||
|
||||
async def test_a_lifetime_breach_stops_the_campaign():
|
||||
repo = FakeRepo([
|
||||
_campaign(
|
||||
guardrails={"max_campaign_spend": 500000, "stop_loss_enabled": True},
|
||||
),
|
||||
])
|
||||
meta = FakeMeta(today=[{"spend": "10.00"}], lifetime=[{"spend": "6000.00"}])
|
||||
|
||||
result = await enforce_once(repo, meta)
|
||||
|
||||
assert result.stopped == ["camp_1"]
|
||||
assert repo.items[0].status == "stopped"
|
||||
assert repo.events[-1]["event_type"] == "campaign.guardrail_stop"
|
||||
# Stopping still pauses on Meta: that is what actually halts delivery.
|
||||
assert meta.status_calls == [("meta_camp_1", "PAUSED")]
|
||||
|
||||
|
||||
async def test_the_trip_is_recorded_on_the_campaign_for_the_ui():
|
||||
repo = FakeRepo([_campaign()])
|
||||
meta = FakeMeta(today=[{"spend": "460.00"}], lifetime=[{"spend": "460.00"}])
|
||||
|
||||
await enforce_once(repo, meta)
|
||||
|
||||
trip = repo.items[0].advanced["last_guardrail_trip"]
|
||||
assert trip["rule"] == "auto_pause_if_spend_reaches"
|
||||
assert trip["observed"] == 46000
|
||||
assert trip["at"]
|
||||
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Guardrail evaluation.
|
||||
|
||||
These rules are the last automatic defence against a campaign spending more
|
||||
than intended, so the tests are mostly about the ways they could fail to
|
||||
fire — or fire when they should not.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from adclaw.campaigns.guardrails import evaluate
|
||||
|
||||
|
||||
def _rules(**overrides):
|
||||
rules = {"stop_loss_enabled": True}
|
||||
rules.update(overrides)
|
||||
return rules
|
||||
|
||||
|
||||
def test_no_rules_means_no_action():
|
||||
assert evaluate({}, today_spend=999999, lifetime_spend=999999) is None
|
||||
|
||||
|
||||
def test_daily_spend_at_the_limit_pauses():
|
||||
"""'reaches' is inclusive: at the limit is at the limit."""
|
||||
breach = evaluate(
|
||||
_rules(auto_pause_if_spend_reaches=45000),
|
||||
today_spend=45000,
|
||||
lifetime_spend=45000,
|
||||
)
|
||||
|
||||
assert breach is not None
|
||||
assert breach.action == "pause"
|
||||
assert breach.rule == "auto_pause_if_spend_reaches"
|
||||
assert breach.observed == 45000
|
||||
|
||||
|
||||
def test_daily_spend_below_the_limit_does_nothing():
|
||||
assert (
|
||||
evaluate(
|
||||
_rules(auto_pause_if_spend_reaches=45000),
|
||||
today_spend=44999,
|
||||
lifetime_spend=44999,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_lifetime_cap_stops_rather_than_pauses():
|
||||
breach = evaluate(
|
||||
_rules(max_campaign_spend=500000),
|
||||
today_spend=1000,
|
||||
lifetime_spend=500000,
|
||||
)
|
||||
|
||||
assert breach.action == "stop"
|
||||
assert breach.rule == "max_campaign_spend"
|
||||
|
||||
|
||||
def test_lifetime_cap_needs_stop_loss_enabled():
|
||||
"""The switch exists so the permanent rule can be turned off alone."""
|
||||
assert (
|
||||
evaluate(
|
||||
_rules(max_campaign_spend=500000, stop_loss_enabled=False),
|
||||
today_spend=1000,
|
||||
lifetime_spend=999999,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_disabling_stop_loss_leaves_the_pause_rules_working():
|
||||
breach = evaluate(
|
||||
_rules(
|
||||
max_campaign_spend=500000,
|
||||
auto_pause_if_spend_reaches=45000,
|
||||
stop_loss_enabled=False,
|
||||
),
|
||||
today_spend=45000,
|
||||
lifetime_spend=999999,
|
||||
)
|
||||
|
||||
assert breach.action == "pause"
|
||||
|
||||
|
||||
def test_the_most_severe_breach_wins():
|
||||
"""Blowing both caps should stop the campaign, not merely pause it."""
|
||||
breach = evaluate(
|
||||
_rules(max_campaign_spend=500000, auto_pause_if_spend_reaches=1000),
|
||||
today_spend=90000,
|
||||
lifetime_spend=600000,
|
||||
)
|
||||
|
||||
assert breach.action == "stop"
|
||||
|
||||
|
||||
def test_cost_per_lead_over_the_limit_pauses():
|
||||
breach = evaluate(
|
||||
_rules(max_cost_per_lead=15000),
|
||||
today_spend=60000,
|
||||
lifetime_spend=60000,
|
||||
cost_per_lead=20000,
|
||||
)
|
||||
|
||||
assert breach.rule == "max_cost_per_lead"
|
||||
assert breach.action == "pause"
|
||||
|
||||
|
||||
def test_cost_rules_are_skipped_before_the_first_lead():
|
||||
"""No leads yet is not an infinite cost per lead."""
|
||||
assert (
|
||||
evaluate(
|
||||
_rules(max_cost_per_lead=100),
|
||||
today_spend=60000,
|
||||
lifetime_spend=60000,
|
||||
cost_per_lead=None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_cost_per_lead_exactly_at_the_limit_is_allowed():
|
||||
"""'max' is a ceiling that may be reached, unlike 'reaches'."""
|
||||
assert (
|
||||
evaluate(
|
||||
_rules(max_cost_per_lead=15000),
|
||||
today_spend=15000,
|
||||
lifetime_spend=15000,
|
||||
cost_per_lead=15000,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_cost_per_click_over_the_limit_pauses():
|
||||
breach = evaluate(
|
||||
_rules(max_cost_per_click=2000),
|
||||
today_spend=60000,
|
||||
lifetime_spend=60000,
|
||||
cost_per_click=2001,
|
||||
)
|
||||
|
||||
assert breach.rule == "max_cost_per_click"
|
||||
|
||||
|
||||
def test_a_limit_of_zero_is_honoured_not_read_as_unset():
|
||||
"""Someone who caps spend at zero means it."""
|
||||
breach = evaluate(
|
||||
_rules(auto_pause_if_spend_reaches=0),
|
||||
today_spend=0,
|
||||
lifetime_spend=0,
|
||||
)
|
||||
|
||||
assert breach is not None
|
||||
assert breach.limit == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "not a number", True, False])
|
||||
def test_an_unusable_limit_is_treated_as_absent(value):
|
||||
assert (
|
||||
evaluate(
|
||||
_rules(auto_pause_if_spend_reaches=value),
|
||||
today_spend=999999,
|
||||
lifetime_spend=999999,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_the_reason_names_the_rule_and_both_numbers():
|
||||
breach = evaluate(
|
||||
_rules(auto_pause_if_spend_reaches=45000),
|
||||
today_spend=46000,
|
||||
lifetime_spend=46000,
|
||||
)
|
||||
|
||||
assert "auto_pause_if_spend_reaches" in breach.reason
|
||||
assert "46000" in breach.reason
|
||||
assert "45000" in breach.reason
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Normalising Meta's insight rows.
|
||||
|
||||
The unit conversion is the point of these tests. Meta reports spend in
|
||||
major units ("12.34" rupees) while budgets and guardrails everywhere else
|
||||
in MaskanX are in minor units (1234 paise). Getting that factor of 100
|
||||
backwards either never trips a guardrail or trips all of them.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from adclaw.meta.insights import (
|
||||
action_value,
|
||||
normalise_row,
|
||||
total_metrics,
|
||||
)
|
||||
|
||||
|
||||
def test_spend_is_converted_from_major_to_minor_units():
|
||||
row = normalise_row({"spend": "12.34"})
|
||||
|
||||
assert row["spend"] == 1234
|
||||
|
||||
|
||||
def test_spend_rounds_rather_than_truncating():
|
||||
"""Truncating under-reports spend, which is how a stop-loss is missed."""
|
||||
assert normalise_row({"spend": "10.999"})["spend"] == 1100
|
||||
assert normalise_row({"spend": "0.005"})["spend"] == 1
|
||||
|
||||
|
||||
def test_missing_and_unparseable_metrics_become_zero():
|
||||
row = normalise_row({"spend": None, "impressions": "not a number"})
|
||||
|
||||
assert row["spend"] == 0
|
||||
assert row["impressions"] == 0
|
||||
|
||||
|
||||
def test_leads_are_dug_out_of_the_actions_array():
|
||||
row = normalise_row({
|
||||
"spend": "300.00",
|
||||
"actions": [
|
||||
{"action_type": "post_engagement", "value": "50"},
|
||||
{"action_type": "lead", "value": "3"},
|
||||
],
|
||||
})
|
||||
|
||||
assert row["leads"] == 3
|
||||
assert row["cost_per_lead"] == 10000 # 300.00 / 3 = 100.00
|
||||
|
||||
|
||||
def test_leads_from_different_meta_action_types_are_summed():
|
||||
"""An on-Facebook lead form and a pixel lead are both leads."""
|
||||
row = normalise_row({
|
||||
"actions": [
|
||||
{"action_type": "lead", "value": "2"},
|
||||
{"action_type": "offsite_conversion.fb_pixel_lead", "value": "5"},
|
||||
],
|
||||
})
|
||||
|
||||
assert row["leads"] == 7
|
||||
|
||||
|
||||
def test_cost_per_lead_is_none_rather_than_infinite_with_no_leads():
|
||||
"""A campaign that has not converted anyone yet must not trip a rule."""
|
||||
row = normalise_row({"spend": "500.00", "actions": []})
|
||||
|
||||
assert row["leads"] == 0
|
||||
assert row["cost_per_lead"] is None
|
||||
|
||||
|
||||
def test_cost_per_click_is_none_with_no_clicks():
|
||||
assert normalise_row({"spend": "500.00", "clicks": "0"})["cost_per_click"] is None
|
||||
|
||||
|
||||
def test_action_value_ignores_malformed_rows():
|
||||
assert action_value(None, ("lead",)) == 0.0
|
||||
assert action_value(["not a dict"], ("lead",)) == 0.0
|
||||
assert action_value([{"action_type": "lead"}], ("lead",)) == 0.0
|
||||
|
||||
|
||||
# --- aggregation ---
|
||||
|
||||
|
||||
def test_totals_of_no_rows_are_zero_not_empty():
|
||||
"""No delivery means nothing spent, which is a fact, not a gap."""
|
||||
totals = total_metrics([])
|
||||
|
||||
assert totals["spend"] == 0
|
||||
assert totals["leads"] == 0
|
||||
assert totals["cost_per_lead"] is None
|
||||
|
||||
|
||||
def test_totals_sum_across_rows():
|
||||
totals = total_metrics([
|
||||
{"spend": "10.00", "clicks": "5", "actions": [{"action_type": "lead", "value": "1"}]},
|
||||
{"spend": "30.00", "clicks": "15", "actions": [{"action_type": "lead", "value": "3"}]},
|
||||
])
|
||||
|
||||
assert totals["spend"] == 4000
|
||||
assert totals["clicks"] == 20
|
||||
assert totals["leads"] == 4
|
||||
|
||||
|
||||
def test_cost_per_lead_is_recomputed_from_totals_not_averaged():
|
||||
"""An average of daily costs is not the cost over the period."""
|
||||
totals = total_metrics([
|
||||
# Day one: 1 lead at 100.00
|
||||
{"spend": "100.00", "actions": [{"action_type": "lead", "value": "1"}]},
|
||||
# Day two: 9 leads at 100.00 total, so ~11.11 each
|
||||
{"spend": "100.00", "actions": [{"action_type": "lead", "value": "9"}]},
|
||||
])
|
||||
|
||||
# 200.00 over 10 leads is 20.00, not the 55.55 an average would give.
|
||||
assert totals["cost_per_lead"] == 2000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impressions,expected", [("0", 0.0), ("1000", 5.0)])
|
||||
def test_ctr_is_recomputed_and_safe_at_zero_impressions(impressions, expected):
|
||||
totals = total_metrics([{"clicks": "50", "impressions": impressions}])
|
||||
|
||||
assert totals["ctr"] == expected
|
||||
Reference in New Issue
Block a user