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>
121 lines
3.8 KiB
Python
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
|