fix(meta): pick an optimisation goal the campaign objective allows
Third failure in the same chain, one object further each time: "Performance goal isn't available: You can't use the selected performance goal with your campaign objective" (code 100, subcode 2490408). DEFAULT_OPTIMIZATION_GOAL was a single global constant, LEAD_GENERATION, applied whatever the objective was. That is correct for OUTCOME_LEADS and wrong for every other objective Meta offers — including OUTCOME_TRAFFIC, which is what the connection test builds. Defaults now come from a per-objective table, and an unknown objective falls back to LINK_CLICKS: valid for the widest range of objectives, where a lead goal is valid for exactly one. An explicit advanced.optimization_goal still wins, and is still checked against the allowlist. Deliberately not validating goal-against-objective beyond the defaults. Meta's full compatibility matrix is not something this code can assert confidently, and a wrong matrix would reject setups that actually work — worse than the failure it would prevent, which sync already surfaces legibly now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,30 @@ from typing import Any
|
|||||||
|
|
||||||
from ..campaigns.models import CampaignSpec
|
from ..campaigns.models import CampaignSpec
|
||||||
|
|
||||||
DEFAULT_OPTIMIZATION_GOAL = "LEAD_GENERATION"
|
# Meta pairs an ad set's optimisation goal with its campaign's objective and
|
||||||
|
# rejects a mismatch with code 100 / subcode 2490408 — at ad-set creation,
|
||||||
|
# which is *after* the campaign object exists. A single global default is
|
||||||
|
# therefore wrong for every objective but one: LEAD_GENERATION on an
|
||||||
|
# OUTCOME_TRAFFIC campaign is what the live connection test hit.
|
||||||
|
#
|
||||||
|
# These are the conservative choice per objective — the goal Meta treats as
|
||||||
|
# that objective's natural optimisation — not the only valid one. A caller
|
||||||
|
# who wants something else sets advanced.optimization_goal, and owns
|
||||||
|
# checking Meta accepts it for their objective.
|
||||||
|
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE = {
|
||||||
|
"OUTCOME_TRAFFIC": "LINK_CLICKS",
|
||||||
|
"OUTCOME_LEADS": "LEAD_GENERATION",
|
||||||
|
"OUTCOME_ENGAGEMENT": "POST_ENGAGEMENT",
|
||||||
|
"OUTCOME_AWARENESS": "REACH",
|
||||||
|
"OUTCOME_SALES": "OFFSITE_CONVERSIONS",
|
||||||
|
"OUTCOME_APP_PROMOTION": "LINK_CLICKS",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback for an objective not in the table above. LINK_CLICKS is valid for
|
||||||
|
# the widest range of objectives, so an unknown objective degrades to
|
||||||
|
# something Meta is most likely to accept rather than to a lead goal that
|
||||||
|
# only OUTCOME_LEADS takes.
|
||||||
|
DEFAULT_OPTIMIZATION_GOAL = "LINK_CLICKS"
|
||||||
DEFAULT_BILLING_EVENT = "IMPRESSIONS"
|
DEFAULT_BILLING_EVENT = "IMPRESSIONS"
|
||||||
|
|
||||||
# Graph requires a bid strategy on every ad set that carries its own budget,
|
# Graph requires a bid strategy on every ad set that carries its own budget,
|
||||||
@@ -145,8 +168,10 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]
|
|||||||
"Set at least one of countries, age_min, age_max or genders.",
|
"Set at least one of countries, age_min, age_max or genders.",
|
||||||
)
|
)
|
||||||
|
|
||||||
optimization_goal = spec.advanced.get(
|
optimization_goal = spec.advanced.get("optimization_goal") or (
|
||||||
"optimization_goal", DEFAULT_OPTIMIZATION_GOAL,
|
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.get(
|
||||||
|
spec.objective or "", DEFAULT_OPTIMIZATION_GOAL,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if optimization_goal not in ALLOWED_OPTIMIZATION_GOALS:
|
if optimization_goal not in ALLOWED_OPTIMIZATION_GOALS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import pytest
|
|||||||
|
|
||||||
from adclaw.meta.client import MetaClient, MetaError
|
from adclaw.meta.client import MetaClient, MetaError
|
||||||
from adclaw.meta.objects import (
|
from adclaw.meta.objects import (
|
||||||
|
ALLOWED_OPTIMIZATION_GOALS,
|
||||||
|
DEFAULT_OPTIMIZATION_GOAL,
|
||||||
|
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE,
|
||||||
build_ad_set_payload,
|
build_ad_set_payload,
|
||||||
build_campaign_payload,
|
build_campaign_payload,
|
||||||
build_creative_payload,
|
build_creative_payload,
|
||||||
@@ -651,3 +654,63 @@ async def test_create_ad_set_sends_the_bid_strategy():
|
|||||||
|
|
||||||
sent = transport.calls[0]["params"]["bid_strategy"]
|
sent = transport.calls[0]["params"]["bid_strategy"]
|
||||||
assert sent == "LOWEST_COST_WITHOUT_CAP"
|
assert sent == "LOWEST_COST_WITHOUT_CAP"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# optimisation goal: Meta pairs it with the campaign objective
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_optimisation_goal_follows_the_objective():
|
||||||
|
"""A traffic campaign cannot optimise for leads.
|
||||||
|
|
||||||
|
Meta rejects the pair with code 100 / subcode 2490408, at ad-set
|
||||||
|
creation — after the campaign object exists. The default used to be
|
||||||
|
LEAD_GENERATION for every objective, which is correct for exactly one
|
||||||
|
of them and is what the live connection test hit.
|
||||||
|
"""
|
||||||
|
assert (
|
||||||
|
build_ad_set_payload(_spec(objective="OUTCOME_TRAFFIC"), campaign_id="c1")[
|
||||||
|
"optimization_goal"
|
||||||
|
]
|
||||||
|
== "LINK_CLICKS"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
build_ad_set_payload(_spec(objective="OUTCOME_LEADS"), campaign_id="c1")[
|
||||||
|
"optimization_goal"
|
||||||
|
]
|
||||||
|
== "LEAD_GENERATION"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
build_ad_set_payload(_spec(objective="OUTCOME_AWARENESS"), campaign_id="c1")[
|
||||||
|
"optimization_goal"
|
||||||
|
]
|
||||||
|
== "REACH"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_mapped_default_is_one_we_allow():
|
||||||
|
"""The table and the allowlist have to agree, or a perfectly ordinary
|
||||||
|
objective raises ValueError on its own default."""
|
||||||
|
for objective, goal in DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.items():
|
||||||
|
assert goal in ALLOWED_OPTIMIZATION_GOALS, objective
|
||||||
|
assert DEFAULT_OPTIMIZATION_GOAL in ALLOWED_OPTIMIZATION_GOALS
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_objective_falls_back_to_the_widest_goal():
|
||||||
|
"""Better a goal most objectives accept than a lead goal only one does."""
|
||||||
|
payload = build_ad_set_payload(_spec(objective="OUTCOME_SOMETHING_NEW"),
|
||||||
|
campaign_id="c1")
|
||||||
|
|
||||||
|
assert payload["optimization_goal"] == "LINK_CLICKS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_explicit_optimisation_goal_still_wins():
|
||||||
|
spec = _spec(
|
||||||
|
objective="OUTCOME_TRAFFIC",
|
||||||
|
advanced={"optimization_goal": "LANDING_PAGE_VIEWS"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = build_ad_set_payload(spec, campaign_id="c1")
|
||||||
|
|
||||||
|
assert payload["optimization_goal"] == "LANDING_PAGE_VIEWS"
|
||||||
|
|||||||
Reference in New Issue
Block a user