Skip to content

Commit 23fa99c

Browse files
✨ Add bid-review command to optimize module
Analyzes keyword performance metrics and estimates bid strength based on impressions and tap-through rate: - STRONG: High impressions with good TTR - MODERATE: Medium performance - WEAK: Low impressions or poor TTR Supports filtering by country, weak-only, and min-impressions. Includes CSV export option.
1 parent a38d63f commit 23fa99c

File tree

2 files changed

+317
-24
lines changed

2 files changed

+317
-24
lines changed

asa_api_cli/optimize.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,3 +959,314 @@ def expand_campaign(
959959
except AppleSearchAdsError as e:
960960
handle_api_error(e)
961961
raise typer.Exit(1) from None
962+
963+
964+
@dataclass
965+
class KeywordBidAnalysis:
966+
"""Analysis of a keyword's bid performance."""
967+
968+
campaign_id: int
969+
campaign_name: str
970+
ad_group_id: int
971+
ad_group_name: str
972+
keyword_id: int
973+
keyword_text: str
974+
current_bid: Decimal
975+
currency: str
976+
impressions: int
977+
taps: int
978+
conversions: int
979+
spend: Decimal
980+
avg_cpt: Decimal | None # Average cost per tap
981+
ttr: float | None # Tap-through rate
982+
cr: float | None # Conversion rate
983+
country: str
984+
985+
@property
986+
def bid_strength(self) -> str:
987+
"""Estimate bid strength based on performance metrics.
988+
989+
Since Apple doesn't expose bidStrength via API, we estimate:
990+
- STRONG: High impressions, good TTR
991+
- MODERATE: Decent impressions, average TTR
992+
- WEAK: Low impressions or poor TTR
993+
"""
994+
if self.impressions == 0:
995+
return "UNKNOWN"
996+
997+
# Calculate TTR if we have data
998+
ttr = self.ttr or 0
999+
1000+
if self.impressions >= 1000 and ttr >= 0.05:
1001+
return "STRONG"
1002+
elif self.impressions >= 100 and ttr >= 0.02:
1003+
return "MODERATE"
1004+
elif self.impressions > 0:
1005+
return "WEAK"
1006+
return "UNKNOWN"
1007+
1008+
@property
1009+
def recommendation(self) -> str:
1010+
"""Suggest bid adjustment based on performance."""
1011+
strength = self.bid_strength
1012+
if strength == "STRONG":
1013+
return "Consider increase for more volume"
1014+
elif strength == "MODERATE":
1015+
return "Monitor performance"
1016+
elif strength == "WEAK":
1017+
return "Increase bid or review keyword"
1018+
return "Need more data"
1019+
1020+
1021+
@app.command("bid-review")
1022+
def review_keyword_bids(
1023+
country: Annotated[
1024+
str | None,
1025+
typer.Option("--country", "-c", help="Filter by country code"),
1026+
] = None,
1027+
days: Annotated[
1028+
int,
1029+
typer.Option("--days", "-d", help="Days of performance data to analyze"),
1030+
] = 30,
1031+
weak_only: Annotated[
1032+
bool,
1033+
typer.Option("--weak", "-w", help="Only show keywords with weak bid strength"),
1034+
] = False,
1035+
min_impressions: Annotated[
1036+
int,
1037+
typer.Option("--min-impressions", help="Minimum impressions to include"),
1038+
] = 0,
1039+
limit: Annotated[
1040+
int,
1041+
typer.Option("--limit", "-l", help="Max keywords to display"),
1042+
] = 50,
1043+
output: Annotated[
1044+
str | None,
1045+
typer.Option("--output", "-o", help="Export to CSV file"),
1046+
] = None,
1047+
) -> None:
1048+
"""Review keyword bids and their performance.
1049+
1050+
Analyzes keyword performance metrics (impressions, taps, conversions)
1051+
to estimate bid strength and suggest optimizations.
1052+
1053+
Since Apple doesn't expose bidStrength via API, this command estimates
1054+
it based on:
1055+
- Impression volume (higher = stronger bid)
1056+
- Tap-through rate (higher = better relevance/position)
1057+
- Conversion metrics
1058+
1059+
Examples:
1060+
asa optimize bid-review --country US
1061+
asa optimize bid-review --country AU --weak # Focus on weak performers
1062+
asa optimize bid-review --days 14 --min-impressions 100
1063+
asa optimize bid-review --output keywords.csv
1064+
"""
1065+
client = get_client()
1066+
1067+
end_date = date.today() - timedelta(days=1)
1068+
start_date = end_date - timedelta(days=days)
1069+
1070+
try:
1071+
with client:
1072+
# Get enabled campaigns
1073+
with spinner("Loading campaigns..."):
1074+
campaigns = list(client.campaigns.find(Selector().where("status", "==", "ENABLED")))
1075+
1076+
if country:
1077+
country = country.upper()
1078+
campaigns = [c for c in campaigns if country in [cc.upper() for cc in c.countries_or_regions]]
1079+
1080+
if not campaigns:
1081+
print_warning("No enabled campaigns found" + (f" for {country}" if country else ""))
1082+
return
1083+
1084+
print_info(f"Analyzing {len(campaigns)} campaigns...")
1085+
1086+
# Collect keyword performance data
1087+
keyword_analyses: list[KeywordBidAnalysis] = []
1088+
1089+
for campaign in campaigns:
1090+
campaign_country = campaign.countries_or_regions[0] if campaign.countries_or_regions else "?"
1091+
1092+
with spinner(f"Fetching keyword data for {campaign.name[:30]}..."):
1093+
try:
1094+
# Get keyword report
1095+
report = client.reports.keywords(
1096+
campaign_id=campaign.id,
1097+
start_date=start_date,
1098+
end_date=end_date,
1099+
granularity=GranularityType.DAILY,
1100+
)
1101+
1102+
for row in report.row:
1103+
if not row.metadata.keyword or not row.total:
1104+
continue
1105+
1106+
impressions = row.total.impressions or 0
1107+
taps = row.total.taps or 0
1108+
conversions = row.total.installs or 0
1109+
spend_amount = row.total.local_spend.amount if row.total.local_spend else "0"
1110+
spend = Decimal(str(spend_amount))
1111+
currency = row.total.local_spend.currency if row.total.local_spend else "USD"
1112+
1113+
# Calculate metrics
1114+
avg_cpt = spend / taps if taps > 0 else None
1115+
ttr = taps / impressions if impressions > 0 else None
1116+
cr = conversions / taps if taps > 0 else None
1117+
1118+
# Get current bid from metadata
1119+
bid_amount = row.metadata.bid_amount.amount if row.metadata.bid_amount else "0"
1120+
bid = Decimal(str(bid_amount))
1121+
1122+
keyword_analyses.append(
1123+
KeywordBidAnalysis(
1124+
campaign_id=campaign.id,
1125+
campaign_name=campaign.name,
1126+
ad_group_id=row.metadata.ad_group_id or 0,
1127+
ad_group_name=row.metadata.ad_group_name or "",
1128+
keyword_id=row.metadata.keyword_id or 0,
1129+
keyword_text=row.metadata.keyword,
1130+
current_bid=bid,
1131+
currency=currency,
1132+
impressions=impressions,
1133+
taps=taps,
1134+
conversions=conversions,
1135+
spend=spend,
1136+
avg_cpt=avg_cpt,
1137+
ttr=ttr,
1138+
cr=cr,
1139+
country=campaign_country,
1140+
)
1141+
)
1142+
1143+
except AppleSearchAdsError:
1144+
continue
1145+
1146+
if not keyword_analyses:
1147+
print_warning("No keyword data found")
1148+
return
1149+
1150+
# Apply filters
1151+
if min_impressions > 0:
1152+
keyword_analyses = [k for k in keyword_analyses if k.impressions >= min_impressions]
1153+
1154+
if weak_only:
1155+
keyword_analyses = [k for k in keyword_analyses if k.bid_strength == "WEAK"]
1156+
1157+
if not keyword_analyses:
1158+
print_warning("No keywords match the specified filters")
1159+
return
1160+
1161+
# Sort by impressions (most first)
1162+
keyword_analyses.sort(key=lambda k: k.impressions, reverse=True)
1163+
1164+
# Export to CSV if requested
1165+
if output:
1166+
try:
1167+
import csv
1168+
1169+
with open(output, "w", newline="") as f:
1170+
writer = csv.writer(f)
1171+
writer.writerow(
1172+
[
1173+
"campaign_name",
1174+
"ad_group_name",
1175+
"keyword",
1176+
"country",
1177+
"current_bid",
1178+
"currency",
1179+
"impressions",
1180+
"taps",
1181+
"conversions",
1182+
"spend",
1183+
"avg_cpt",
1184+
"ttr",
1185+
"cr",
1186+
"bid_strength",
1187+
"recommendation",
1188+
]
1189+
)
1190+
for k in keyword_analyses:
1191+
writer.writerow(
1192+
[
1193+
k.campaign_name,
1194+
k.ad_group_name,
1195+
k.keyword_text,
1196+
k.country,
1197+
k.current_bid,
1198+
k.currency,
1199+
k.impressions,
1200+
k.taps,
1201+
k.conversions,
1202+
k.spend,
1203+
k.avg_cpt,
1204+
f"{k.ttr:.4f}" if k.ttr else "",
1205+
f"{k.cr:.4f}" if k.cr else "",
1206+
k.bid_strength,
1207+
k.recommendation,
1208+
]
1209+
)
1210+
print_success(f"Exported {len(keyword_analyses)} keywords to {output}")
1211+
except Exception as e:
1212+
print_error("Export failed", str(e))
1213+
1214+
# Display table
1215+
display_count = min(limit, len(keyword_analyses))
1216+
1217+
table = Table(title=f"Keyword Bid Review ({days} days)")
1218+
table.add_column("Keyword", style="cyan", max_width=25)
1219+
table.add_column("Campaign", style="magenta", max_width=20)
1220+
table.add_column("Country", width=4)
1221+
table.add_column("Bid", justify="right", width=8)
1222+
table.add_column("Impr", justify="right", width=8)
1223+
table.add_column("TTR", justify="right", width=6)
1224+
table.add_column("Strength", justify="center", width=10)
1225+
1226+
for k in keyword_analyses[:display_count]:
1227+
# Color code strength
1228+
strength = k.bid_strength
1229+
if strength == "STRONG":
1230+
strength_display = "[green]STRONG[/green]"
1231+
elif strength == "MODERATE":
1232+
strength_display = "[yellow]MODERATE[/yellow]"
1233+
elif strength == "WEAK":
1234+
strength_display = "[red]WEAK[/red]"
1235+
else:
1236+
strength_display = "[dim]?[/dim]"
1237+
1238+
ttr_display = f"{k.ttr * 100:.1f}%" if k.ttr else "—"
1239+
1240+
table.add_row(
1241+
k.keyword_text[:25],
1242+
k.campaign_name[:20],
1243+
k.country,
1244+
f"{k.current_bid:.2f}",
1245+
f"{k.impressions:,}",
1246+
ttr_display,
1247+
strength_display,
1248+
)
1249+
1250+
console.print(table)
1251+
1252+
if len(keyword_analyses) > display_count:
1253+
print_info(f"Showing {display_count} of {len(keyword_analyses)} keywords. Use --limit to see more.")
1254+
1255+
# Summary
1256+
strong = sum(1 for k in keyword_analyses if k.bid_strength == "STRONG")
1257+
moderate = sum(1 for k in keyword_analyses if k.bid_strength == "MODERATE")
1258+
weak = sum(1 for k in keyword_analyses if k.bid_strength == "WEAK")
1259+
1260+
console.print("\n[dim]Bid Strength Summary:[/dim]")
1261+
console.print(f" [green]Strong:[/green] {strong}")
1262+
console.print(f" [yellow]Moderate:[/yellow] {moderate}")
1263+
console.print(f" [red]Weak:[/red] {weak}")
1264+
1265+
if weak > 0:
1266+
console.print(
1267+
f"\n[yellow]💡 {weak} keywords have weak bid strength - consider increasing bids[/yellow]"
1268+
)
1269+
1270+
except AppleSearchAdsError as e:
1271+
handle_api_error(e)
1272+
raise typer.Exit(1) from None

uv.lock

Lines changed: 6 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)