Files
AFFAANhandClaude Opus 5 6c39d42874 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>
2026-08-03 18:04:25 +05:30

121 lines
3.8 KiB
Python

# -*- 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