@@ -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
0 commit comments