budget_calc.py.backup 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. """
  2. 预算计算引擎 — 出价调整与账户评估
  3. 核心机制:通过调整 oCPM 出价(bid_amount)控制消耗速度,不设日预算限制。
  4. 决策矩阵:ROI × 跑量 二维分类,5 种动作(keep/increase/decrease/close/observe)。
  5. 缩量、扩量、持平各一套矩阵,调整幅度 5%-15%。
  6. """
  7. import logging
  8. from datetime import datetime, timedelta
  9. from pathlib import Path
  10. from typing import Any, Dict, List, Optional
  11. from agent.tools import tool
  12. from agent.tools.models import ToolContext, ToolResult
  13. from examples.auto_put_ad.tools.ad_api import ad_update
  14. from examples.auto_put_ad.tools.data_query import _get_odps_client
  15. logger = logging.getLogger(__name__)
  16. # ===== 常量 =====
  17. MIN_BID = 10 # 最低出价 0.10 元(10分)
  18. MAX_BID = 10000 # 最高出价 100 元(10000分)
  19. TOP_RATIO = 0.30 # 优质广告占比(保留兼容,新逻辑用分位数)
  20. # 动作类型
  21. ACTION_KEEP = "keep"
  22. ACTION_INCREASE = "increase"
  23. ACTION_DECREASE = "decrease"
  24. ACTION_CLOSE = "close"
  25. ACTION_OBSERVE = "observe"
  26. def _determine_strategy(scale_ratio: float) -> str:
  27. """根据缩量/扩量比例判断策略"""
  28. if scale_ratio < 0.7:
  29. return "aggressive_scale_down"
  30. elif scale_ratio < 0.95:
  31. return "moderate_scale_down"
  32. elif scale_ratio <= 1.05:
  33. return "maintain"
  34. elif scale_ratio <= 1.3:
  35. return "moderate_scale_up"
  36. else:
  37. return "aggressive_scale_up"
  38. def _compute_thresholds(df_valid) -> dict:
  39. """基于有效广告池计算分位数阈值
  40. Returns:
  41. dict with keys: roi_p70, roi_p30, cost_p50
  42. """
  43. return {
  44. "roi_p70": float(df_valid["efficiency"].quantile(0.70)),
  45. "roi_p30": float(df_valid["efficiency"].quantile(0.30)),
  46. "cost_p50": float(df_valid["cost"].quantile(0.50)),
  47. }
  48. def _classify_ad(efficiency: float, cost: float, thresholds: dict) -> tuple:
  49. """将广告分类到 ROI × 跑量 二维象限
  50. Returns:
  51. (roi_level, volume_level): e.g. ("high", "high")
  52. """
  53. if efficiency >= thresholds["roi_p70"]:
  54. roi_level = "high"
  55. elif efficiency >= thresholds["roi_p30"]:
  56. roi_level = "mid"
  57. else:
  58. roi_level = "low"
  59. volume_level = "high" if cost >= thresholds["cost_p50"] else "low"
  60. return roi_level, volume_level
  61. def _decide_action(roi_level: str, volume_level: str, strategy: str) -> tuple:
  62. """根据 ROI×跑量 分类 + 策略,返回 (action, adj_ratio)
  63. 三套矩阵:缩量 / 扩量 / 持平
  64. """
  65. # 缩量矩阵(aggressive / moderate)
  66. if strategy in ("aggressive_scale_down", "moderate_scale_down"):
  67. aggressive = strategy == "aggressive_scale_down"
  68. matrix = {
  69. ("high", "high"): (ACTION_KEEP, 0.0),
  70. ("high", "low"): (ACTION_KEEP, 0.0),
  71. ("mid", "high"): (ACTION_DECREASE, -0.10 if aggressive else -0.05),
  72. ("mid", "low"): (ACTION_OBSERVE, 0.0),
  73. ("low", "high"): (ACTION_DECREASE, -0.15 if aggressive else -0.10),
  74. ("low", "low"): (ACTION_CLOSE, 0.0),
  75. }
  76. return matrix[(roi_level, volume_level)]
  77. # 扩量矩阵(aggressive / moderate)
  78. if strategy in ("aggressive_scale_up", "moderate_scale_up"):
  79. aggressive = strategy == "aggressive_scale_up"
  80. matrix = {
  81. ("high", "high"): (ACTION_KEEP, 0.0),
  82. ("high", "low"): (ACTION_INCREASE, 0.15 if aggressive else 0.10),
  83. ("mid", "high"): (ACTION_KEEP, 0.0),
  84. ("mid", "low"): (ACTION_INCREASE, 0.05),
  85. ("low", "high"): (ACTION_DECREASE, -0.10),
  86. ("low", "low"): (ACTION_CLOSE, 0.0),
  87. }
  88. return matrix[(roi_level, volume_level)]
  89. # 持平矩阵
  90. if roi_level == "low" and volume_level == "low":
  91. return (ACTION_CLOSE, 0.0)
  92. return (ACTION_KEEP, 0.0)
  93. def _parse_bizdate(bizdate: str) -> tuple:
  94. """解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)"""
  95. if bizdate in ("yesterday", ""):
  96. biz = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  97. else:
  98. biz = bizdate.replace("-", "")
  99. biz_dash = f"{biz[:4]}-{biz[4:6]}-{biz[6:]}"
  100. return biz, biz_dash
  101. def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
  102. """构建昨日效率数据 SQL(广告维度聚合)
  103. 包含冷启动保护所需字段:
  104. - create_time: 广告创建时间(判定冷启动期 48h)
  105. - conversions_count: 转化量(判定赔付门槛 6 次)
  106. """
  107. return f"""
  108. SELECT
  109. a.account_id,
  110. a.ad_id,
  111. c.ad_name,
  112. c.create_time,
  113. SUM(b.cost/100) AS cost,
  114. SUM(b.valid_click_count) AS valid_click_count,
  115. SUM(b.conversions_count) AS conversions_count,
  116. SUM(t.首层小程序打开数) AS open_count,
  117. SUM(t.裂变0层回流数) AS fission0_count,
  118. SUM(t.总回流人数) AS total_return_count
  119. FROM (
  120. SELECT
  121. IF(c.creative_name IS NOT NULL, c.creative_name, t.rootsourceid) AS creative_name,
  122. t.*
  123. FROM loghubods.touliu_data t
  124. LEFT JOIN (
  125. SELECT DISTINCT creative_name,
  126. SPLIT(GET_JSON_OBJECT(page_spec,'$.wechat_mini_program_spec.mini_program_path'),'rootSourceId%3D')[1] AS rootsourceid
  127. FROM loghubods.ad_put_tencent_creative_components a
  128. LEFT JOIN loghubods.ad_put_tencent_creative_day b ON a.creative_id = b.creative_id
  129. WHERE page_type = 'PAGE_TYPE_WECHAT_MINI_PROGRAM'
  130. ) c ON c.rootsourceid = t.rootsourceid
  131. WHERE t.dt = '{biz}'
  132. ) t
  133. LEFT JOIN loghubods.ad_put_tencent_creative_day a ON t.creative_name = a.creative_name
  134. LEFT JOIN loghubods.ad_put_tencent_ad c ON a.ad_id = c.ad_id
  135. LEFT JOIN (
  136. SELECT creative_id, valid_click_count, cost, conversions_count
  137. FROM (
  138. SELECT creative_id, valid_click_count, cost, conversions_count,
  139. ROW_NUMBER() OVER (PARTITION BY creative_id ORDER BY update_time DESC) AS rank
  140. FROM loghubods.ad_put_tencent_creative_data_day
  141. WHERE dt = '{biz_dash}'
  142. ) t WHERE rank = 1
  143. ) b ON a.creative_id = b.creative_id
  144. WHERE t.dt = '{biz}'
  145. AND a.ad_id IS NOT NULL
  146. AND b.cost IS NOT NULL
  147. AND b.cost > 0
  148. GROUP BY a.account_id, a.ad_id, c.ad_name, c.create_time
  149. """
  150. # ===== 账户级评估 =====
  151. @tool(description="评估各账户昨日投放表现,输出账户健康度和稳定性标签")
  152. async def account_evaluate(
  153. bizdate: str = "yesterday",
  154. context: Optional[ToolContext] = None,
  155. ) -> ToolResult:
  156. """
  157. 账户级评估:查询各账户昨日汇总数据,按消耗量判断稳定性。
  158. Args:
  159. bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
  160. """
  161. import pandas as pd
  162. try:
  163. client = _get_odps_client()
  164. if client is None:
  165. return ToolResult(title="account_evaluate 失败", output="ODPS 客户端未初始化")
  166. biz, biz_dash = _parse_bizdate(bizdate)
  167. # 查询各账户昨日汇总(复用效率 SQL,按 account_id 聚合)
  168. inner_sql = _build_efficiency_sql(biz, biz_dash)
  169. sql = f"""
  170. SELECT
  171. account_id,
  172. COUNT(DISTINCT ad_id) AS ad_count,
  173. SUM(cost) AS total_cost,
  174. SUM(open_count) AS total_open,
  175. SUM(fission0_count) AS total_fission0
  176. FROM ({inner_sql}) t
  177. GROUP BY account_id
  178. """
  179. df = client.execute_sql(sql)
  180. if df.empty:
  181. return ToolResult(title="account_evaluate", output=f"昨日({biz})无账户数据")
  182. # 计算效率分均值
  183. df["avg_efficiency"] = df.apply(
  184. lambda r: r["total_fission0"] / r["total_cost"] if r["total_cost"] and r["total_cost"] > 0 else 0,
  185. axis=1,
  186. )
  187. # 按消耗量判断稳定性(中位数为阈值)
  188. median_cost = df["total_cost"].median()
  189. p30_cost = df["total_cost"].quantile(0.30)
  190. def label_stability(cost):
  191. if cost >= median_cost:
  192. return "稳定"
  193. elif cost >= p30_cost:
  194. return "一般"
  195. else:
  196. return "低量"
  197. df["stability"] = df["total_cost"].apply(label_stability)
  198. df = df.sort_values("total_cost", ascending=False).reset_index(drop=True)
  199. # 格式化输出
  200. lines = [
  201. f"账户评估({biz},共 {len(df)} 个账户)",
  202. f"消耗中位数: {median_cost:,.0f}元(≥中位数=稳定,≥P30=一般,<P30=低量)",
  203. "",
  204. f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
  205. "-" * 55,
  206. ]
  207. for _, row in df.iterrows():
  208. lines.append(
  209. f"{int(row['account_id']):<15} {int(row['ad_count']):>6} "
  210. f"{row['total_cost']:>12,.0f} {row['avg_efficiency']:>10.4f} "
  211. f"{row['stability']:>6}"
  212. )
  213. # 标记建议扩量的账户
  214. stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
  215. if not stable_high_eff.empty:
  216. lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
  217. for _, row in stable_high_eff.iterrows():
  218. lines.append(f" 账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
  219. return ToolResult(
  220. title=f"账户评估({len(df)}个账户)",
  221. output="\n".join(lines),
  222. metadata={
  223. "accounts": df.to_dict("records"),
  224. "median_cost": median_cost,
  225. "bizdate": biz,
  226. },
  227. )
  228. except Exception as e:
  229. logger.error("account_evaluate 失败: %s", e, exc_info=True)
  230. return ToolResult(title="account_evaluate 失败", output=str(e))
  231. # ===== 出价调整 =====
  232. @tool(description="基于昨日裂变效率数据计算今日出价调整方案(ROI×跑量二维矩阵,5种动作)")
  233. async def budget_calculate_from_data(
  234. account_id: int,
  235. total_budget_yuan: float,
  236. bizdate: str = "yesterday",
  237. strategy: str = "auto",
  238. min_bid_cents: int = MIN_BID,
  239. context: Optional[ToolContext] = None,
  240. ) -> ToolResult:
  241. """
  242. 智能出价调整(ROI × 跑量 二维决策矩阵):
  243. 1. 拉取昨日效率数据(按广告维度聚合)
  244. 2. 拉取当前广告出价/状态
  245. 3. 计算分位数阈值(ROI P70/P30 + 消耗 P50)
  246. 4. 每个广告分类到二维象限,决定动作(keep/increase/decrease/close/observe)
  247. 5. 样本不足广告跳过不操作
  248. Args:
  249. account_id: 账户ID(传 0 则不过滤账户)
  250. total_budget_yuan: 今日总预算(元)
  251. bizdate: 业务日期,格式 YYYYMMDD 或 "yesterday"
  252. strategy: "auto" 或手动指定策略
  253. min_bid_cents: 最低出价(分,默认 10)
  254. """
  255. import pandas as pd
  256. try:
  257. client = _get_odps_client()
  258. if client is None:
  259. return ToolResult(title="budget_calculate_from_data 失败", output="ODPS 客户端未初始化")
  260. biz, biz_dash = _parse_bizdate(bizdate)
  261. # Step 1: 昨日效率数据
  262. logger.info("拉取昨日效率数据: bizdate=%s", biz)
  263. sql_efficiency = _build_efficiency_sql(biz, biz_dash)
  264. df_eff = client.execute_sql(sql_efficiency)
  265. if df_eff.empty:
  266. return ToolResult(title="budget_calculate_from_data 失败", output=f"昨日({biz})效率数据为空")
  267. # Step 2: 当前广告出价/状态
  268. ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
  269. ad_ids_str = ",".join(map(str, ad_ids))
  270. sql_status = f"""
  271. SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
  272. FROM loghubods.ad_put_tencent_ad
  273. WHERE ad_id IN ({ad_ids_str})
  274. """
  275. df_status = client.execute_sql(sql_status)
  276. # Step 3: 合并
  277. df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
  278. df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
  279. df = pd.merge(
  280. df_eff,
  281. df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
  282. on="ad_id", how="left",
  283. )
  284. # Step 4: 效率分 + 有效广告筛选
  285. df["efficiency"] = df.apply(
  286. lambda r: r["fission0_count"] / r["cost"]
  287. if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
  288. else None,
  289. axis=1,
  290. )
  291. df_valid = df[df["open_count"] >= 100].copy().sort_values("efficiency", ascending=False).reset_index(drop=True)
  292. df_nosample = df[df["open_count"] < 100].copy()
  293. if df_valid.empty:
  294. return ToolResult(title="budget_calculate_from_data", output="无有效广告(open_count >= 100)")
  295. # Step 5: 计算分位数阈值
  296. thresholds = _compute_thresholds(df_valid)
  297. # Step 6: 判断策略
  298. yesterday_total = float(df_valid["cost"].sum())
  299. scale_ratio = total_budget_yuan / yesterday_total if yesterday_total > 0 else 1.0
  300. if strategy == "auto":
  301. strategy = _determine_strategy(scale_ratio)
  302. # Step 7: 二维矩阵决策(含冷启动保护)
  303. results = []
  304. cold_start_count = 0
  305. for _, row in df_valid.iterrows():
  306. eff = float(row["efficiency"]) if pd.notna(row["efficiency"]) else 0.0
  307. cost = float(row["cost"])
  308. conversions = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
  309. # --- 冷启动保护判定(优先于决策矩阵) ---
  310. is_cold_start = False
  311. cold_start_reason = ""
  312. # 判定1:广告创建时间 < 48 小时
  313. create_time = row.get("create_time")
  314. if create_time and pd.notna(create_time):
  315. try:
  316. if isinstance(create_time, str):
  317. ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
  318. else:
  319. ct = pd.Timestamp(create_time).to_pydatetime()
  320. hours_since_creation = (datetime.now() - ct).total_seconds() / 3600
  321. if hours_since_creation < 48:
  322. is_cold_start = True
  323. cold_start_reason = f"冷启动期({hours_since_creation:.0f}h<48h)"
  324. except (ValueError, TypeError):
  325. pass
  326. # 判定2:转化数不足 < 6
  327. if conversions < 6 and not is_cold_start:
  328. is_cold_start = True
  329. cold_start_reason = f"转化不足({conversions}<6)"
  330. if is_cold_start:
  331. cold_start_count += 1
  332. roi_level, volume_level = _classify_ad(eff, cost, thresholds)
  333. action = ACTION_OBSERVE
  334. adj_ratio = 0.0
  335. else:
  336. roi_level, volume_level = _classify_ad(eff, cost, thresholds)
  337. action, adj_ratio = _decide_action(roi_level, volume_level, strategy)
  338. # --- 关停保护:赔付门槛检查 ---
  339. if action == ACTION_CLOSE and 3 <= conversions < 6:
  340. action = ACTION_OBSERVE
  341. adj_ratio = 0.0
  342. cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到6)"
  343. bid = row["bid_amount"] if pd.notna(row["bid_amount"]) else None
  344. new_bid = None
  345. if bid and action in (ACTION_INCREASE, ACTION_DECREASE):
  346. new_bid = max(min_bid_cents, min(MAX_BID, int(float(bid) * (1 + adj_ratio))))
  347. elif bid:
  348. new_bid = int(float(bid)) # keep/observe/close 不改出价
  349. results.append({
  350. "date": datetime.now().strftime("%Y-%m-%d"),
  351. "ad_id": int(row["ad_id"]),
  352. "ad_name": str(row["ad_name"]) if pd.notna(row["ad_name"]) else "",
  353. "account_id": int(row["account_id"]) if pd.notna(row["account_id"]) else 0,
  354. "roi_level": roi_level,
  355. "volume_level": volume_level,
  356. "efficiency": round(eff, 4),
  357. "cost": round(cost, 2),
  358. "open_count": int(row["open_count"]),
  359. "conversions_count": conversions,
  360. "is_cold_start": is_cold_start,
  361. "cold_start_reason": cold_start_reason,
  362. "current_bid": int(float(bid)) if bid else None,
  363. "new_bid": new_bid,
  364. "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "—",
  365. "action": action,
  366. "ad_status": str(row["ad_status"]) if pd.notna(row.get("ad_status")) else "",
  367. })
  368. # Step 8: 格式化输出(按动作分组)
  369. direction = "缩量" if scale_ratio < 1 else "扩量" if scale_ratio > 1 else "持平"
  370. lines = [
  371. f"出价调整方案({direction} {abs(1-scale_ratio)*100:.0f}%)",
  372. f"昨日消耗: {yesterday_total:,.0f} 元 → 今日预算: {total_budget_yuan:,.0f} 元",
  373. f"策略: {strategy}",
  374. f"阈值: ROI P70={thresholds['roi_p70']:.4f}, P30={thresholds['roi_p30']:.4f}, 消耗 P50={thresholds['cost_p50']:.0f}元",
  375. "",
  376. ]
  377. action_labels = [
  378. (ACTION_KEEP, "保持不动"),
  379. (ACTION_INCREASE, "提价放量"),
  380. (ACTION_DECREASE, "降价控量"),
  381. (ACTION_CLOSE, "建议关停"),
  382. (ACTION_OBSERVE, "观察不动"),
  383. ]
  384. for act, label in action_labels:
  385. sub = [r for r in results if r["action"] == act]
  386. if not sub:
  387. continue
  388. lines.append(f"【{label}({act})- {len(sub)}个】")
  389. for item in sub[:5]:
  390. bid_info = f"出价:{item['current_bid']}→{item['new_bid']}分 {item['adjustment_ratio']}" if item["current_bid"] else "无出价"
  391. lines.append(
  392. f" {item['ad_id']} | ROI:{item['roi_level']}/量:{item['volume_level']} | "
  393. f"效率:{item['efficiency']} | 消耗:{item['cost']:.0f}元 | {bid_info}"
  394. )
  395. if len(sub) > 5:
  396. lines.append(f" ... 还有 {len(sub)-5} 个")
  397. lines.append("")
  398. if len(df_nosample) > 0:
  399. lines.append(f"【样本不足 - {len(df_nosample)}个,本次不操作】")
  400. lines.append("")
  401. if cold_start_count > 0:
  402. cold_start_ads = [r for r in results if r.get("is_cold_start")]
  403. lines.append(f"【冷启动保护 - {cold_start_count}个,标记observe不调价】")
  404. for item in cold_start_ads[:5]:
  405. lines.append(f" {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']} | 消耗:{item['cost']:.0f}元")
  406. if cold_start_count > 5:
  407. lines.append(f" ... 还有 {cold_start_count - 5} 个")
  408. lines.append("")
  409. # 汇总
  410. action_counts = {}
  411. for r in results:
  412. action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
  413. summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
  414. lines.append(f"合计:{' / '.join(summary_parts)} / 样本不足:{len(df_nosample)} / 冷启动保护:{cold_start_count}")
  415. # Step 9: 输出 Excel(按动作颜色标识)
  416. try:
  417. import openpyxl
  418. from openpyxl.styles import PatternFill, Font
  419. ACTION_COLORS = {
  420. "increase": "C6EFCE", # 绿
  421. "decrease": "FFEB9C", # 橙黄
  422. "close": "FFC7CE", # 红
  423. "observe": "FFFF99", # 黄
  424. "keep": "FFFFFF", # 白
  425. }
  426. output_dir = Path(__file__).parent.parent / "outputs"
  427. output_dir.mkdir(exist_ok=True)
  428. xlsx_path = output_dir / f"adjustment_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
  429. headers_cn = ["日期", "账户ID", "广告ID", "广告名称", "动作", "当前出价(分)", "新出价(分)",
  430. "调整幅度", "ROI等级", "跑量等级", "效率分", "昨日消耗(元)", "打开数",
  431. "转化数", "冷启动", "冷启动原因", "广告状态"]
  432. fields_en = ["date", "account_id", "ad_id", "ad_name", "action", "current_bid", "new_bid",
  433. "adjustment_ratio", "roi_level", "volume_level", "efficiency", "cost",
  434. "open_count", "conversions_count", "is_cold_start", "cold_start_reason", "ad_status"]
  435. wb = openpyxl.Workbook()
  436. ws = wb.active
  437. ws.title = "调整方案"
  438. # 表头(加粗)
  439. ws.append(headers_cn)
  440. for cell in ws[1]:
  441. cell.font = Font(bold=True)
  442. # 数据行 + 颜色
  443. for row in results:
  444. ws.append([row.get(f) for f in fields_en])
  445. color = ACTION_COLORS.get(row.get("action", "keep"), "FFFFFF")
  446. fill = PatternFill(fill_type="solid", fgColor=color)
  447. for cell in ws[ws.max_row]:
  448. cell.fill = fill
  449. # 冻结首行 + 列宽
  450. ws.freeze_panes = "A2"
  451. for col in ws.columns:
  452. ws.column_dimensions[col[0].column_letter].width = 16
  453. wb.save(xlsx_path)
  454. lines.append(f"\n📄 完整方案已输出: {xlsx_path}")
  455. logger.info("方案已保存: %s", xlsx_path)
  456. except Exception as xlsx_err:
  457. logger.warning("xlsx 输出失败(不影响主流程): %s", xlsx_err)
  458. return ToolResult(
  459. title=f"出价调整方案({len(results)}个广告,{direction}{abs(1-scale_ratio)*100:.0f}%)",
  460. output="\n".join(lines),
  461. metadata={
  462. "adjustment_plan": results,
  463. "strategy": strategy,
  464. "scale_ratio": scale_ratio,
  465. "thresholds": thresholds,
  466. "yesterday_total": yesterday_total,
  467. "total_budget_yuan": total_budget_yuan,
  468. "nosample_count": len(df_nosample),
  469. "cold_start_count": cold_start_count,
  470. "action_counts": action_counts,
  471. },
  472. )
  473. except Exception as e:
  474. logger.error("budget_calculate_from_data 失败: %s", e, exc_info=True)
  475. return ToolResult(title="budget_calculate_from_data 失败", output=str(e))
  476. # ===== 执行出价调整 =====
  477. @tool(description="执行出价调整方案")
  478. async def bid_adjustment_execute(
  479. adjustment_plan: List[Dict],
  480. account_id: int,
  481. context: Optional[ToolContext] = None,
  482. ) -> ToolResult:
  483. """
  484. 批量执行出价调整
  485. Args:
  486. adjustment_plan: 调整方案列表,每项包含 ad_id, new_bid, action
  487. account_id: 账户ID
  488. """
  489. success_count = 0
  490. failed_count = 0
  491. errors = []
  492. for item in adjustment_plan:
  493. if item["action"] not in ("increase", "decrease"):
  494. continue
  495. try:
  496. await ad_update(
  497. account_id=account_id,
  498. adgroup_id=item["ad_id"],
  499. bid_amount=item["new_bid"]
  500. )
  501. success_count += 1
  502. logger.info("调整出价: ad_id=%s, new_bid=%s", item["ad_id"], item["new_bid"])
  503. except Exception as e:
  504. failed_count += 1
  505. error_msg = f"ad_id={item['ad_id']}: {str(e)}"
  506. errors.append(error_msg)
  507. logger.error("执行失败: %s", error_msg)
  508. output_lines = [
  509. "执行完成:",
  510. f"- 成功调整: {success_count} 个",
  511. f"- 失败: {failed_count} 个",
  512. ]
  513. if errors:
  514. output_lines.append("\n失败详情:")
  515. for err in errors[:10]:
  516. output_lines.append(f" {err}")
  517. if len(errors) > 10:
  518. output_lines.append(f" ... 还有 {len(errors)-10} 个错误")
  519. return ToolResult(
  520. title="出价调整执行结果",
  521. output="\n".join(output_lines),
  522. )