openppc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of openppc might be problematic. Click here for more details.
- openppc/__init__.py +3 -0
- openppc/__main__.py +3 -0
- openppc/checkfacts.py +96 -0
- openppc/cli.py +78 -0
- openppc/engine/__init__.py +1 -0
- openppc/engine/benchmarks.py +94 -0
- openppc/engine/findings.py +223 -0
- openppc/engine/trace.py +920 -0
- openppc/engine/waste_model.py +122 -0
- openppc/facts.py +49 -0
- openppc/ingest/__init__.py +3 -0
- openppc/ingest/account.py +126 -0
- openppc/ingest/google_ads_csv.py +184 -0
- openppc/mcp_server.py +144 -0
- openppc/templates/__init__.py +41 -0
- openppc/templates/_common.py +134 -0
- openppc/templates/account_read.py +124 -0
- openppc/templates/account_structure.py +466 -0
- openppc/templates/keyword_audit.py +134 -0
- openppc/templates/search_term_waste.py +685 -0
- openppc/webapi.py +157 -0
- openppc-0.1.0.dist-info/METADATA +224 -0
- openppc-0.1.0.dist-info/RECORD +26 -0
- openppc-0.1.0.dist-info/WHEEL +4 -0
- openppc-0.1.0.dist-info/entry_points.txt +3 -0
- openppc-0.1.0.dist-info/licenses/LICENSE +21 -0
openppc/__init__.py
ADDED
openppc/__main__.py
ADDED
openppc/checkfacts.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Facts for `openppc check`: every figure a reasonable audit of your files could cite.
|
|
2
|
+
|
|
3
|
+
For each export we register the account totals, every row, and every campaign, ad group
|
|
4
|
+
and match type group: cost, clicks, impressions, conversions, CTR, CPC, cost per
|
|
5
|
+
conversion, conversion rate, and each one's share of the total. Then we add every number
|
|
6
|
+
our own templates would print. Template thresholds are left out on purpose: a threshold
|
|
7
|
+
we chose is not a fact about your account.
|
|
8
|
+
"""
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
|
|
11
|
+
from .engine import benchmarks
|
|
12
|
+
from .facts import Fact, FactBook
|
|
13
|
+
from .ingest import load_report
|
|
14
|
+
from .ingest.account import is_snapshot, read_json, snapshot
|
|
15
|
+
from .templates import TEMPLATES, account_read, account_structure
|
|
16
|
+
from .templates._common import register_group, totals
|
|
17
|
+
|
|
18
|
+
ROW_KEYS = ("search_term", "keyword", "ad_group", "campaign")
|
|
19
|
+
GROUP_KEYS = ("campaign", "ad_group", "match_type")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def facts_for_report(report):
|
|
23
|
+
book = FactBook(report.currency)
|
|
24
|
+
grand = totals(report.rows)
|
|
25
|
+
register_group(book, "", grand, grand)
|
|
26
|
+
if report.days:
|
|
27
|
+
book.count("days in the date range", report.days, "days")
|
|
28
|
+
row_key = next((k for k in ROW_KEYS if report.has(k)), None)
|
|
29
|
+
never_rows, word = [], ""
|
|
30
|
+
if row_key:
|
|
31
|
+
rows_of = {} # Google lists a search term once per campaign, ad group and match type
|
|
32
|
+
for row in report.rows:
|
|
33
|
+
if row.get(row_key):
|
|
34
|
+
rows_of.setdefault(" ".join(row[row_key].lower().split()), []).append(row)
|
|
35
|
+
start = len(book.facts)
|
|
36
|
+
for members in rows_of.values():
|
|
37
|
+
name = members[0][row_key].strip()
|
|
38
|
+
register_group(book, name, totals(members), grand) # the term's total, as our reports print it
|
|
39
|
+
if len(members) > 1:
|
|
40
|
+
for row in members: # and each row, as the export shows it
|
|
41
|
+
register_group(book, name, totals([row]), grand)
|
|
42
|
+
book.facts[start:] = [replace(f, level="row") for f in book.facts[start:]]
|
|
43
|
+
word = {"search_term": "search terms", "keyword": "keywords"}.get(row_key, row_key.replace("_", " ") + "s")
|
|
44
|
+
book.count(f"{word} in this export", len(rows_of), "terms")
|
|
45
|
+
never = [members for members in rows_of.values() if totals(members).conversions == 0]
|
|
46
|
+
never_rows = [r for members in never for r in members]
|
|
47
|
+
if never: # what audits usually call waste: every term that never converted, whatever it cost
|
|
48
|
+
zt = totals(never_rows)
|
|
49
|
+
book.count(f"{word} with no conversions", len(never), "terms")
|
|
50
|
+
book.money(f"cost of all {word} with no conversions", zt.cost, "cost")
|
|
51
|
+
book.count(f"clicks of all {word} with no conversions", zt.clicks, "clicks")
|
|
52
|
+
if grand.cost:
|
|
53
|
+
book.pct(f"cost of all {word} with no conversions as a share of total cost",
|
|
54
|
+
zt.cost / grand.cost * 100, "cost")
|
|
55
|
+
for key in GROUP_KEYS:
|
|
56
|
+
if key != row_key and report.has(key):
|
|
57
|
+
groups = {}
|
|
58
|
+
for row in report.rows:
|
|
59
|
+
if row.get(key):
|
|
60
|
+
groups.setdefault(row[key], []).append(row)
|
|
61
|
+
start = len(book.facts)
|
|
62
|
+
for name, members in groups.items():
|
|
63
|
+
register_group(book, name, totals(members), grand)
|
|
64
|
+
if key == "match_type" and row_key and never_rows: # "broad match was 62% of the waste"
|
|
65
|
+
waste = totals(never_rows).cost
|
|
66
|
+
mine = totals([r for r in never_rows if r.get(key) == name]).cost
|
|
67
|
+
if waste:
|
|
68
|
+
book.pct(f"'{name}' share of the cost of {word} with no conversions", mine / waste * 100,
|
|
69
|
+
"cost", name)
|
|
70
|
+
book.facts[start:] = [replace(f, level="group") for f in book.facts[start:]]
|
|
71
|
+
for template in TEMPLATES.values():
|
|
72
|
+
if template.INPUT_KIND == "report" and template.accepts(report):
|
|
73
|
+
_, template_book = template.run(report)
|
|
74
|
+
book.facts.extend(template_book.facts)
|
|
75
|
+
return [f for f in book.facts if f.metric != "rule"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def facts_for_paths(paths, industry=None):
|
|
79
|
+
facts = []
|
|
80
|
+
for path in paths:
|
|
81
|
+
if str(path).lower().endswith(".json"):
|
|
82
|
+
raw = read_json(path)
|
|
83
|
+
if is_snapshot(raw):
|
|
84
|
+
_, book, _ = account_structure.evaluate(snapshot(raw, source=str(path)))
|
|
85
|
+
else:
|
|
86
|
+
_, book = account_read.run(account_read.load_metrics(path))
|
|
87
|
+
facts += [f for f in book.facts if f.metric != "rule"]
|
|
88
|
+
else:
|
|
89
|
+
facts += facts_for_report(load_report(path))
|
|
90
|
+
if industry:
|
|
91
|
+
b = benchmarks.lookup(industry)
|
|
92
|
+
facts += [Fact(f"{b['name']} average CPC", b["cpc"], "money", "cpc"),
|
|
93
|
+
Fact(f"{b['name']} average CTR", b["ctr"], "pct", "ctr"),
|
|
94
|
+
Fact(f"{b['name']} average conversion rate", b["cvr"], "pct", "cvr"),
|
|
95
|
+
Fact(f"{b['name']} average cost per lead", b["cpl"], "money", "cpa")]
|
|
96
|
+
return facts
|
openppc/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""OpenPPC command line.
|
|
2
|
+
|
|
3
|
+
openppc templates
|
|
4
|
+
openppc industries
|
|
5
|
+
openppc audit search-term-waste exports/search_terms.csv --industry home-services
|
|
6
|
+
openppc check --audit their_audit.md --data exports/search_terms.csv
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 clean, 1 bad input, 2 the check found problems, 3 our own report failed
|
|
9
|
+
its number check (a bug in OpenPPC).
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .checkfacts import facts_for_paths
|
|
17
|
+
from .engine import benchmarks
|
|
18
|
+
from .engine.trace import render_check, trace
|
|
19
|
+
from .templates import TEMPLATES, run_template
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _emit(text, out):
|
|
23
|
+
if out:
|
|
24
|
+
Path(out).write_text(text + "\n", encoding="utf-8")
|
|
25
|
+
print(f"wrote {out}", file=sys.stderr)
|
|
26
|
+
else:
|
|
27
|
+
print(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parser():
|
|
31
|
+
ap = argparse.ArgumentParser(
|
|
32
|
+
prog="openppc", description="Read-only Google Ads audits where every number traces back to your data.")
|
|
33
|
+
ap.add_argument("--version", action="version", version=f"openppc {__version__}")
|
|
34
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
35
|
+
sub.add_parser("templates", help="list the audit templates")
|
|
36
|
+
sub.add_parser("industries", help="list the benchmark industries")
|
|
37
|
+
a = sub.add_parser("audit", help="run a template on an export")
|
|
38
|
+
a.add_argument("template", choices=sorted(TEMPLATES))
|
|
39
|
+
a.add_argument("data", help="your export (.csv) or two-period totals (.json)")
|
|
40
|
+
a.add_argument("--industry", help="compare against an industry average, e.g. home-services")
|
|
41
|
+
a.add_argument("--min-cost", type=float, default=None,
|
|
42
|
+
help="cost threshold for a waste flag (default: about $20, in the export's currency)")
|
|
43
|
+
a.add_argument("--brand", help="your brand names, comma-separated; brand terms are never flagged as waste")
|
|
44
|
+
a.add_argument("--out", help="write the report here instead of printing it")
|
|
45
|
+
c = sub.add_parser("check", help="check the numbers in any audit against your data")
|
|
46
|
+
c.add_argument("--audit", required=True, help="the audit to check (.md or .txt)")
|
|
47
|
+
c.add_argument("--data", required=True, action="append", help="an export or totals file; repeat for more")
|
|
48
|
+
c.add_argument("--industry", help="also accept this industry's published averages")
|
|
49
|
+
c.add_argument("--out", help="write the result here instead of printing it")
|
|
50
|
+
return ap
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None):
|
|
54
|
+
args = _parser().parse_args(argv)
|
|
55
|
+
try:
|
|
56
|
+
if args.command == "templates":
|
|
57
|
+
for t in TEMPLATES.values():
|
|
58
|
+
print(f"{t.NAME:20} {t.TIER:5} reads a {t.INPUT}\n{'':27}{t.SUMMARY}")
|
|
59
|
+
return 0
|
|
60
|
+
if args.command == "industries":
|
|
61
|
+
for key, row in benchmarks.TABLE.items():
|
|
62
|
+
print(f"{key:20} {row[0]}")
|
|
63
|
+
return 0
|
|
64
|
+
if args.command == "audit":
|
|
65
|
+
markdown, _, passed = run_template(args.template, args.data, industry=args.industry,
|
|
66
|
+
min_cost=args.min_cost, brand=args.brand)
|
|
67
|
+
_emit(markdown, args.out)
|
|
68
|
+
return 0 if passed else 3
|
|
69
|
+
if args.command == "check":
|
|
70
|
+
text = Path(args.audit).read_text(encoding="utf-8")
|
|
71
|
+
claims, contradictions = trace(text, facts_for_paths(args.data, args.industry))
|
|
72
|
+
_emit(render_check(claims, contradictions), args.out)
|
|
73
|
+
clean = all(c.verdict == "traced" for c in claims) and not contradictions
|
|
74
|
+
return 0 if clean else 2
|
|
75
|
+
except (ValueError, FileNotFoundError, KeyError) as e:
|
|
76
|
+
print(f"openppc: {e}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
return 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The engine: findings (the arithmetic), benchmarks (the baseline), trace (the checker)."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Industry benchmarks: the public baseline an audit is compared against.
|
|
2
|
+
|
|
3
|
+
Published averages from WordStream / LocaliQ, "Google Ads Benchmarks 2026": US search
|
|
4
|
+
campaigns on Google Ads and Microsoft Ads, April 2025 to March 2026. Use them as a
|
|
5
|
+
ballpark, never a target: they are US-only, annual averages that blend both platforms,
|
|
6
|
+
and every advertiser defines a conversion differently.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
SOURCE = "WordStream / LocaliQ Google Ads Benchmarks 2026, US search, Apr 2025 to Mar 2026"
|
|
10
|
+
|
|
11
|
+
# key: (display name, CPC, CTR %, conversion rate %, cost per lead)
|
|
12
|
+
TABLE = {
|
|
13
|
+
"all": ("All industries", 5.42, 6.64, 8.18, 66.69),
|
|
14
|
+
"animals-pets": ("Animals & Pets", 4.06, 7.49, 16.22, 31.50),
|
|
15
|
+
"apparel": ("Apparel, Fashion & Jewelry", 4.44, 6.64, 4.50, 97.51),
|
|
16
|
+
"arts-entertainment": ("Arts & Entertainment", 1.63, 12.75, 5.91, 26.84),
|
|
17
|
+
"legal": ("Attorneys & Legal Services", 9.87, 5.87, 5.55, 131.63),
|
|
18
|
+
"auto-sales": ("Automotive, For Sale", 2.27, 8.28, 6.01, 44.26),
|
|
19
|
+
"auto-repair": ("Automotive, Repair, Service & Parts", 4.35, 5.56, 15.51, 29.96),
|
|
20
|
+
"beauty": ("Beauty & Personal Care", 4.62, 6.75, 10.35, 39.25),
|
|
21
|
+
"business-services": ("Business Services", 5.87, 6.10, 4.85, 93.69),
|
|
22
|
+
"career": ("Career & Employment", 5.81, 5.88, 3.05, 67.36),
|
|
23
|
+
"dental": ("Dentists & Dental Services", 8.00, 5.66, 10.67, 72.97),
|
|
24
|
+
"education": ("Education & Instruction", 4.81, 7.56, 13.14, 77.48),
|
|
25
|
+
"finance": ("Finance & Insurance", 3.39, 9.83, 2.64, 74.44),
|
|
26
|
+
"furniture": ("Furniture", 3.97, 6.57, 2.99, 106.70),
|
|
27
|
+
"health-fitness": ("Health & Fitness", 6.17, 5.81, 6.94, 67.36),
|
|
28
|
+
"home-services": ("Home & Home Improvement", 8.33, 6.47, 8.05, 90.92),
|
|
29
|
+
"industrial": ("Industrial & Commercial", 5.87, 6.57, 8.20, 75.19),
|
|
30
|
+
"personal-services": ("Personal Services", 7.17, 7.16, 12.34, 54.60),
|
|
31
|
+
"physicians": ("Physicians & Surgeons", 4.76, 6.61, 12.43, 40.04),
|
|
32
|
+
"real-estate": ("Real Estate", 3.22, 7.61, 3.70, 102.51),
|
|
33
|
+
"restaurants": ("Restaurants & Food", 2.05, 6.83, 8.05, 30.57),
|
|
34
|
+
"shopping": ("Shopping, Collectibles & Gifts", 4.14, 8.28, 4.01, 49.40),
|
|
35
|
+
"sports-recreation": ("Sports & Recreation", 2.77, 8.75, 7.69, 44.26),
|
|
36
|
+
"travel": ("Travel", 2.14, 9.32, 5.83, 44.70),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lookup(industry):
|
|
41
|
+
key = (industry or "").strip().lower()
|
|
42
|
+
for k, (name, cpc, ctr, cvr, cpl) in TABLE.items():
|
|
43
|
+
if key in (k, name.lower()):
|
|
44
|
+
return {"key": k, "name": name, "cpc": cpc, "ctr": ctr, "cvr": cvr, "cpl": cpl}
|
|
45
|
+
raise ValueError(f"unknown industry '{industry}'. Try one of: {', '.join(TABLE)}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compare(book, industry, cost, clicks, impressions, conversions, currency="USD"):
|
|
49
|
+
"""The account's ratios next to the industry's averages, every figure registered in the FactBook.
|
|
50
|
+
Returns the industry row and one dict per metric the data allows. The averages are in US dollars, so an
|
|
51
|
+
account in another currency is compared on rates only (CTR, conversion rate), never on costs."""
|
|
52
|
+
b = lookup(industry)
|
|
53
|
+
dollars = (currency or "USD").upper() == "USD"
|
|
54
|
+
metrics = []
|
|
55
|
+
if clicks and dollars:
|
|
56
|
+
metrics.append(("CPC", cost / clicks, b["cpc"], "money", "cpc", True))
|
|
57
|
+
if impressions:
|
|
58
|
+
metrics.append(("CTR", clicks / impressions * 100, b["ctr"], "pct", "ctr", False))
|
|
59
|
+
if clicks:
|
|
60
|
+
metrics.append(("Conversion rate", conversions / clicks * 100, b["cvr"], "pct", "cvr", False))
|
|
61
|
+
if conversions and dollars:
|
|
62
|
+
metrics.append(("Cost per conversion", cost / conversions, b["cpl"], "money", "cpa", True))
|
|
63
|
+
rows = []
|
|
64
|
+
for name, yours, avg, kind, metric, lower_is_better in metrics:
|
|
65
|
+
if kind == "money":
|
|
66
|
+
y = book.money(f"your {name}", yours, metric)
|
|
67
|
+
a = book.money(f"{b['name']} average {name}", avg, metric)
|
|
68
|
+
else:
|
|
69
|
+
y = book.pct(f"your {name}", yours, metric, dp=2)
|
|
70
|
+
a = book.pct(f"{b['name']} average {name}", avg, metric, dp=2)
|
|
71
|
+
rows.append({"name": name, "yours": yours, "average": avg, "yours_text": y, "average_text": a,
|
|
72
|
+
"side": "lower" if yours < avg else "higher",
|
|
73
|
+
"better": (yours < avg) if lower_is_better else (yours > avg)})
|
|
74
|
+
return b, rows
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def section(book, industry, cost, clicks, impressions, conversions, currency="USD"):
|
|
78
|
+
"""Markdown lines comparing account ratios with the industry averages.
|
|
79
|
+
Every figure is registered in the FactBook, so the checker can trace it."""
|
|
80
|
+
b, rows = compare(book, industry, cost, clicks, impressions, conversions, currency)
|
|
81
|
+
lines = ["", f"## Against the {b['name']} average", "",
|
|
82
|
+
f"_Source: {SOURCE}. A ballpark, not a target: US-only, averaged over a year, and "
|
|
83
|
+
"every advertiser counts conversions differently. The industry figure for cost per "
|
|
84
|
+
"conversion is cost per lead._", ""]
|
|
85
|
+
if (currency or "USD").upper() != "USD":
|
|
86
|
+
lines += [f"_This account is in {currency.upper()} and the averages are in US dollars, so cost per click "
|
|
87
|
+
"and cost per conversion are left out._", ""]
|
|
88
|
+
if not rows:
|
|
89
|
+
return lines
|
|
90
|
+
lines += ["| Metric | Yours | Industry average | Read |", "|---|---|---|---|"]
|
|
91
|
+
for r in rows:
|
|
92
|
+
lines.append(f"| {r['name']} | {r['yours_text']} | {r['average_text']} | "
|
|
93
|
+
f"{r['side']} ({'better' if r['better'] else 'worse'}) |")
|
|
94
|
+
return lines
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Findings block: the arithmetic and the pattern read, done in code, before any model.
|
|
2
|
+
|
|
3
|
+
WHY: when we tested language models on real Google Ads accounts, they reversed spend,
|
|
4
|
+
reach and CPA directions and invented figures, even after fine-tuning. Those are not
|
|
5
|
+
language tasks. This module computes every delta, names every direction, classifies
|
|
6
|
+
the account pattern with explicit rules, and hands any writer a block of facts it
|
|
7
|
+
cannot get wrong.
|
|
8
|
+
|
|
9
|
+
The rules are a media buyer's playbook. Read them, argue with them, send a PR.
|
|
10
|
+
|
|
11
|
+
Ported from the Second Step audit engine (September 2026). Every threshold is a named
|
|
12
|
+
constant below, and each one has a row in rulebook/rules.csv saying where it comes from.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
from openppc.engine.findings import build_findings
|
|
16
|
+
block = build_findings(dict(cs=460, ps=1144, ci=11238, pi=38647, cc=279, pc=640,
|
|
17
|
+
cv=41, pv=50), meta=dict(currency="USD",
|
|
18
|
+
business_model="sailing club (membership lead-gen)"))
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
FLAT = 0.03 # |change| below this is "flat"
|
|
22
|
+
SMALL_SAMPLE = 10 # fewer conversions than this in BOTH periods: do not trend CPA
|
|
23
|
+
NOISE_VOLUME = 50 # under this many conversions a period...
|
|
24
|
+
NOISE_MOVE = 0.10 # ...a conversion move smaller than this is one or two events: flat
|
|
25
|
+
HELD = 0.20 # spend cut and conversions within this of prior: they held
|
|
26
|
+
CPA_BAND = 0.10 # CPA within this of prior: efficiency held
|
|
27
|
+
DILUTION_REACH = 0.50 # impressions up at least this much...
|
|
28
|
+
DILUTION_CTR = 0.70 # ...while CTR fell to this share of prior or less: reach dilution
|
|
29
|
+
TIGHTEN_REACH = 0.30 # impressions down at least this much...
|
|
30
|
+
TIGHTEN_CTR = 1.30 # ...while CTR rose to this multiple of prior or more: tightening
|
|
31
|
+
CVR_DROP = 0.70 # conversion rate at or below this share of prior...
|
|
32
|
+
CVR_DROP_CLICKS = 200 # ...on at least this many clicks
|
|
33
|
+
MICRO_CVR = 0.25 # this share of clicks converting: engagement actions, not outcomes
|
|
34
|
+
THIN_DATA = 30 # conversions a period Google wants before automated bidding is judged
|
|
35
|
+
SEASONAL_WORDS = ("seasonal", "rafting", "ski", "charter")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pct(c, p):
|
|
39
|
+
if not p:
|
|
40
|
+
return None
|
|
41
|
+
return (c - p) / p
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _dir(c, p):
|
|
45
|
+
d = _pct(c, p)
|
|
46
|
+
if d is None:
|
|
47
|
+
return "n/a"
|
|
48
|
+
if abs(d) < FLAT:
|
|
49
|
+
return "flat"
|
|
50
|
+
return "up" if d > 0 else "down"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt(v, dp=0):
|
|
54
|
+
if v is None:
|
|
55
|
+
return "n/a"
|
|
56
|
+
return f"{v:,.{dp}f}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pc(c, p):
|
|
60
|
+
d = _pct(c, p)
|
|
61
|
+
if d is None:
|
|
62
|
+
return ""
|
|
63
|
+
if abs(d) < FLAT:
|
|
64
|
+
return " (flat)"
|
|
65
|
+
return f" ({'+' if d > 0 else '-'}{abs(d) * 100:.0f}%)"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _line(name, c, p, dp=0, unit="", lower_is_better=False):
|
|
69
|
+
"""'Spend: 460, from 1,144. DOWN 60%.' Direction word is explicit and upper-case."""
|
|
70
|
+
d = _dir(c, p)
|
|
71
|
+
word = {"up": "UP", "down": "DOWN", "flat": "FLAT", "n/a": "no prior base"}[d]
|
|
72
|
+
tail = "" if d in ("flat", "n/a") else f" {abs(_pct(c, p)) * 100:.0f}%"
|
|
73
|
+
verdict = ""
|
|
74
|
+
if lower_is_better and d in ("up", "down"):
|
|
75
|
+
verdict = " (improved)" if d == "down" else " (worse)"
|
|
76
|
+
return f"- {name}: {_fmt(c, dp)}{unit}, from {_fmt(p, dp)}{unit}. {word}{tail}{verdict}."
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def classify(m):
|
|
80
|
+
"""Return (primary_read, flags[]) from the eight raw metrics. Rules in priority order."""
|
|
81
|
+
cs, ps, ci, pi, cc, pc, cv, pv = (m[k] for k in ("cs", "ps", "ci", "pi", "cc", "pc", "cv", "pv"))
|
|
82
|
+
cpa_c = cs / cv if cv else None
|
|
83
|
+
cpa_p = ps / pv if pv else None
|
|
84
|
+
ctr_c = cc / ci if ci else 0
|
|
85
|
+
ctr_p = pc / pi if pi else 0
|
|
86
|
+
cvr_c = cv / cc if cc else 0
|
|
87
|
+
cvr_p = pv / pc if pc else 0
|
|
88
|
+
sp = _dir(cs, ps)
|
|
89
|
+
cv_d = _dir(cv, pv)
|
|
90
|
+
if max(cv, pv) < NOISE_VOLUME and _pct(cv, pv) is not None and abs(_pct(cv, pv)) < NOISE_MOVE:
|
|
91
|
+
cv_d = "flat"
|
|
92
|
+
cpa_d = _dir(cpa_c, cpa_p) if (cpa_c and cpa_p) else "n/a"
|
|
93
|
+
flags = []
|
|
94
|
+
|
|
95
|
+
# ---- primary read: first matching rule wins ------------------------------------
|
|
96
|
+
if cv == 0 and pv == 0:
|
|
97
|
+
read = ("NO TRACKED CONVERSIONS IN EITHER PERIOD. Verify tracking before reading "
|
|
98
|
+
"performance. Every cost figure here is meaningless until it fires.")
|
|
99
|
+
elif cv == 0:
|
|
100
|
+
read = (f"CONVERSIONS WENT TO ZERO, from {_fmt(pv, 1)}, on {sp} spend. Tracking is "
|
|
101
|
+
"the first suspect, before any performance conclusion.")
|
|
102
|
+
elif pv == 0:
|
|
103
|
+
read = (f"CONVERSIONS APPEARED FROM ZERO ({_fmt(cv, 1)}). Either tracking started "
|
|
104
|
+
"working or the account did. Confirm which before crediting the account.")
|
|
105
|
+
elif max(cv, pv) < SMALL_SAMPLE:
|
|
106
|
+
read = (f"SAMPLE TOO SMALL: {_fmt(cv, 1)} vs {_fmt(pv, 1)} conversions. Direction is "
|
|
107
|
+
"noise at this volume. Do not read CPA movement as a trend or act on it.")
|
|
108
|
+
elif sp == "down" and -HELD <= _pct(cv, pv) <= FLAT and cpa_d == "down":
|
|
109
|
+
read = (f"EFFICIENCY WIN. Spend cut {abs(_pct(cs, ps)) * 100:.0f}%, conversions held at "
|
|
110
|
+
f"{cv / pv * 100:.0f}% of prior, CPA improved {abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. "
|
|
111
|
+
"Volume is the constraint, not efficiency. Falling conversions here are a GOOD outcome.")
|
|
112
|
+
elif sp == "down" and _pct(cv, pv) < -HELD and cpa_d == "down":
|
|
113
|
+
read = (f"EFFICIENT CONTRACTION. Spend cut {abs(_pct(cs, ps)) * 100:.0f}% and conversions fell "
|
|
114
|
+
f"{abs(_pct(cv, pv)) * 100:.0f}%, less than spend, so CPA improved "
|
|
115
|
+
f"{abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. The cut removed waste first. Volume is now the constraint; "
|
|
116
|
+
"the falling conversion count is the price of the efficiency, not a failure.")
|
|
117
|
+
elif sp == "down" and cv_d == "down" and (cpa_d in ("flat", "n/a") or
|
|
118
|
+
(cpa_c and cpa_p and abs(_pct(cpa_c, cpa_p)) <= CPA_BAND)):
|
|
119
|
+
read = ("BUDGET REDUCTION, EFFICIENCY HELD. Spend, clicks and conversions fell together; "
|
|
120
|
+
f"CPA moved within {CPA_BAND * 100:.0f}%. Volume fell because spend fell, not because performance broke.")
|
|
121
|
+
elif sp == "down" and cv_d == "down" and cpa_d == "up":
|
|
122
|
+
read = (f"CONTRACTION WITH WORSENING EFFICIENCY. Spend down {abs(_pct(cs, ps)) * 100:.0f}% "
|
|
123
|
+
f"but conversions fell faster ({abs(_pct(cv, pv)) * 100:.0f}%), so CPA rose "
|
|
124
|
+
f"{abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. The cut removed converting volume, not just waste.")
|
|
125
|
+
elif sp in ("up", "flat") and cv_d == "down" and cpa_d == "up":
|
|
126
|
+
if sp == "up":
|
|
127
|
+
read = (f"OVER-EXPANSION. Spend up {abs(_pct(cs, ps)) * 100:.0f}% while conversions fell "
|
|
128
|
+
f"{abs(_pct(cv, pv)) * 100:.0f}%; CPA up {abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. "
|
|
129
|
+
"More money bought worse traffic, or the funnel broke after the click. This is the worst pattern.")
|
|
130
|
+
else:
|
|
131
|
+
read = (f"DETERIORATION AFTER THE CLICK. Spend flat, conversions down "
|
|
132
|
+
f"{abs(_pct(cv, pv)) * 100:.0f}%, CPA up {abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. "
|
|
133
|
+
"Traffic volume is similar; what changed is landing page, form, offer or tracking, not targeting.")
|
|
134
|
+
elif cv_d == "up" and cpa_d == "down":
|
|
135
|
+
read = (f"EFFICIENCY GAIN. Conversions up {abs(_pct(cv, pv)) * 100:.0f}% and CPA down "
|
|
136
|
+
f"{abs(_pct(cpa_c, cpa_p)) * 100:.0f}% on {sp} spend. More output at lower cost.")
|
|
137
|
+
elif cv_d == "up" and sp == "up" and cpa_d == "up":
|
|
138
|
+
read = (f"GROWTH AT A WORSENING RATE. Conversions up {abs(_pct(cv, pv)) * 100:.0f}% but spend up "
|
|
139
|
+
f"{abs(_pct(cs, ps)) * 100:.0f}% and CPA up {abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. "
|
|
140
|
+
"The marginal conversion costs far more than the average. Growth is real; efficiency is degrading.")
|
|
141
|
+
elif cv_d == "up" and cpa_d in ("flat", "n/a"):
|
|
142
|
+
read = (f"CLEAN SCALE. Conversions up {abs(_pct(cv, pv)) * 100:.0f}% with CPA flat. "
|
|
143
|
+
"Spend and output grew together at stable efficiency.")
|
|
144
|
+
elif cv_d == "flat" and cpa_d == "up":
|
|
145
|
+
read = (f"PAYING MORE FOR THE SAME. Conversions flat, CPA up {abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. "
|
|
146
|
+
"Extra spend or reach returned nothing.")
|
|
147
|
+
elif cv_d == "flat" and cpa_d == "down":
|
|
148
|
+
read = (f"SAME OUTPUT, CHEAPER. Conversions flat, CPA down {abs(_pct(cpa_c, cpa_p)) * 100:.0f}%. "
|
|
149
|
+
"Efficiency improved without volume changing.")
|
|
150
|
+
else:
|
|
151
|
+
read = "MIXED OR FLAT. No single dominant pattern; read the metric lines individually."
|
|
152
|
+
|
|
153
|
+
# ---- secondary flags: independent of the primary read ---------------------------
|
|
154
|
+
if _pct(ci, pi) is not None and _pct(ci, pi) >= DILUTION_REACH and ctr_p and ctr_c / ctr_p <= DILUTION_CTR:
|
|
155
|
+
flags.append(f"REACH DILUTION: impressions up {_pct(ci, pi) * 100:.0f}% while CTR fell from "
|
|
156
|
+
f"{ctr_p * 100:.2f}% to {ctr_c * 100:.2f}%. Broad matching or audience expansion is buying low-intent reach.")
|
|
157
|
+
if _pct(ci, pi) is not None and _pct(ci, pi) <= -TIGHTEN_REACH and ctr_p and ctr_c / ctr_p >= TIGHTEN_CTR:
|
|
158
|
+
flags.append(f"TIGHTENING: reach cut {abs(_pct(ci, pi)) * 100:.0f}% while CTR rose from "
|
|
159
|
+
f"{ctr_p * 100:.2f}% to {ctr_c * 100:.2f}%. Targeting narrowed and relevance improved.")
|
|
160
|
+
if cvr_p and cvr_c / cvr_p <= CVR_DROP and cc >= CVR_DROP_CLICKS:
|
|
161
|
+
flags.append(f"CONVERSION RATE fell from {cvr_p * 100:.2f}% to {cvr_c * 100:.2f}%. "
|
|
162
|
+
"Clicks are converting worse; suspect landing page, offer, tracking, or click quality.")
|
|
163
|
+
if cvr_c >= MICRO_CVR:
|
|
164
|
+
flags.append(f"MICRO-CONVERSIONS LIKELY: {cvr_c * 100:.0f}% of clicks convert. These are engagement "
|
|
165
|
+
"actions, not outcomes. Do not read CPA as cost per lead or booking.")
|
|
166
|
+
if 0 < max(cv, pv) < THIN_DATA:
|
|
167
|
+
flags.append(f"THIN DATA: under {THIN_DATA} conversions a period is too few to judge automated bidding on.")
|
|
168
|
+
return read, flags
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def biggest_move(m):
|
|
172
|
+
"""Which metric moved most, dollar-weighted: CPA and conversions outrank reach."""
|
|
173
|
+
cs, ps, cv, pv = m["cs"], m["ps"], m["cv"], m["pv"]
|
|
174
|
+
cpa_c = cs / cv if cv else None
|
|
175
|
+
cpa_p = ps / pv if pv else None
|
|
176
|
+
cands = []
|
|
177
|
+
if cpa_c and cpa_p:
|
|
178
|
+
cands.append(("CPA", _pct(cpa_c, cpa_p)))
|
|
179
|
+
if pv:
|
|
180
|
+
cands.append(("conversions", _pct(cv, pv)))
|
|
181
|
+
if ps:
|
|
182
|
+
cands.append(("spend", _pct(cs, ps)))
|
|
183
|
+
if not cands:
|
|
184
|
+
return "not computable"
|
|
185
|
+
name, d = max(cands, key=lambda x: abs(x[1]))
|
|
186
|
+
return f"{name} {'up' if d > 0 else 'down'} {abs(d) * 100:.0f}%"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_findings(m, meta=None):
|
|
190
|
+
meta = meta or {}
|
|
191
|
+
cs, ps, ci, pi, cc, pc, cv, pv = (m[k] for k in ("cs", "ps", "ci", "pi", "cc", "pc", "cv", "pv"))
|
|
192
|
+
cpa_c = cs / cv if cv else None
|
|
193
|
+
cpa_p = ps / pv if pv else None
|
|
194
|
+
ctr_c = cc / ci * 100 if ci else 0
|
|
195
|
+
ctr_p = pc / pi * 100 if pi else 0
|
|
196
|
+
read, flags = classify(m)
|
|
197
|
+
bm = (meta.get("business_model") or "").lower()
|
|
198
|
+
if any(w in bm for w in SEASONAL_WORDS):
|
|
199
|
+
flags.append("SEASONAL BUSINESS: period-over-period is the wrong lens. Most of any change is the "
|
|
200
|
+
"calendar. Compare against the same period last year before crediting the account.")
|
|
201
|
+
cur = meta.get("currency", "USD")
|
|
202
|
+
if cur and cur.upper() != "USD":
|
|
203
|
+
flags.append(f"CURRENCY IS {cur.upper()}: do not compare these figures to USD accounts.")
|
|
204
|
+
|
|
205
|
+
lines = [
|
|
206
|
+
"COMPUTED FINDINGS (facts, already calculated; restate them, never recompute or reverse them):",
|
|
207
|
+
_line("Spend", cs, ps),
|
|
208
|
+
_line("Impressions", ci, pi),
|
|
209
|
+
_line("Clicks", cc, pc),
|
|
210
|
+
f"- CTR: {ctr_c:.2f}%, from {ctr_p:.2f}%. {_dir(ctr_c, ctr_p).upper()}.",
|
|
211
|
+
_line("Conversions", cv, pv, dp=1 if (cv % 1 or pv % 1) else 0),
|
|
212
|
+
(_line("CPA", cpa_c, cpa_p, dp=2, lower_is_better=True)
|
|
213
|
+
if (cpa_c and cpa_p) else f"- CPA: {_fmt(cpa_c, 2)}, from {_fmt(cpa_p, 2)}."),
|
|
214
|
+
"",
|
|
215
|
+
f"READ: {read}",
|
|
216
|
+
f"BIGGEST MOVE: {biggest_move(m)}",
|
|
217
|
+
]
|
|
218
|
+
if flags:
|
|
219
|
+
lines.append("FLAGS:")
|
|
220
|
+
lines += [f"- {f}" for f in flags]
|
|
221
|
+
else:
|
|
222
|
+
lines.append("FLAGS: none")
|
|
223
|
+
return "\n".join(lines)
|