Просмотр исходного кода

feat(budget): Step 0-1 完成 - 策略配置层 + 工具拆分

- 新增 strategy_config.py: 配置层工具(load/update/history)
- 新增 budget_strategy_v1.json: 默认策略配置
- 重构 budget_calc.py: 3层架构(数据/计算/执行)
  - 数据层: get_ad_performance, get_account_summary
  - 计算层: compute_budget_thresholds, classify_ads, compute_bid_adjustment
  - 所有策略参数外部化,不再硬编码
- 保留向后兼容别名
刘立冬 1 месяц назад
Родитель
Сommit
6bb1725426

+ 84 - 0
examples/auto_put_ad/configs/budget_strategy_v1.json

@@ -0,0 +1,84 @@
+{
+  "version": "v1.0",
+  "last_updated": "2026-04-09",
+  "updated_by": "system",
+  "update_reason": "初始化默认配置,沿用当前硬编码值",
+
+  "thresholds": {
+    "roi_high_percentile": 0.70,
+    "roi_low_percentile": 0.30,
+    "cost_mid_percentile": 0.50,
+    "valid_ad_min_open_count": 100,
+    "valid_ad_min_cost": 0
+  },
+
+  "strategy_boundaries": {
+    "aggressive_scale_down": [0, 0.70],
+    "moderate_scale_down": [0.70, 0.95],
+    "maintain": [0.95, 1.05],
+    "moderate_scale_up": [1.05, 1.30],
+    "aggressive_scale_up": [1.30, 999]
+  },
+
+  "decision_matrix": {
+    "aggressive_scale_down": {
+      "high_high": ["keep", 0.0],
+      "high_low":  ["keep", 0.0],
+      "mid_high":  ["decrease", -0.10],
+      "mid_low":   ["observe", 0.0],
+      "low_high":  ["decrease", -0.15],
+      "low_low":   ["close", 0.0]
+    },
+    "moderate_scale_down": {
+      "high_high": ["keep", 0.0],
+      "high_low":  ["keep", 0.0],
+      "mid_high":  ["decrease", -0.05],
+      "mid_low":   ["observe", 0.0],
+      "low_high":  ["decrease", -0.10],
+      "low_low":   ["close", 0.0]
+    },
+    "moderate_scale_up": {
+      "high_high": ["keep", 0.0],
+      "high_low":  ["increase", 0.10],
+      "mid_high":  ["keep", 0.0],
+      "mid_low":   ["increase", 0.05],
+      "low_high":  ["decrease", -0.10],
+      "low_low":   ["close", 0.0]
+    },
+    "aggressive_scale_up": {
+      "high_high": ["keep", 0.0],
+      "high_low":  ["increase", 0.15],
+      "mid_high":  ["keep", 0.0],
+      "mid_low":   ["increase", 0.05],
+      "low_high":  ["decrease", -0.10],
+      "low_low":   ["close", 0.0]
+    },
+    "maintain": {
+      "high_high": ["keep", 0.0],
+      "high_low":  ["keep", 0.0],
+      "mid_high":  ["keep", 0.0],
+      "mid_low":   ["keep", 0.0],
+      "low_high":  ["keep", 0.0],
+      "low_low":   ["close", 0.0]
+    }
+  },
+
+  "bid_limits": {
+    "min_bid": 10,
+    "max_bid": 10000,
+    "max_increase_pct": 0.15,
+    "max_decrease_pct": -0.15
+  },
+
+  "protection": {
+    "cold_start_enabled": true,
+    "cold_start_hours": 48,
+    "cold_start_min_conversions": 6,
+    "compensation_enabled": true,
+    "compensation_min_conversions": 6,
+    "compensation_cpa_deviation_pct": 0.20,
+    "min_adjustment_interval_hours": 2
+  },
+
+  "history": []
+}

+ 408 - 324
examples/auto_put_ad/tools/budget_calc.py

@@ -1,117 +1,37 @@
 """
 """
 预算计算引擎 — 出价调整与账户评估
 预算计算引擎 — 出价调整与账户评估
 
 
-核心机制:通过调整 oCPM 出价(bid_amount)控制消耗速度,不设日预算限制。
-决策矩阵:ROI × 跑量 二维分类,5 种动作(keep/increase/decrease/close/observe)。
-缩量、扩量、持平各一套矩阵,调整幅度 5%-15%。
+改造后的三层架构:
+- 数据层:get_ad_performance / get_account_summary — 获取原始数据
+- 计算层:compute_budget_thresholds / classify_ads / compute_bid_adjustment — 确定性计算
+- 执行层:bid_adjustment_execute — 调用 API 执行调整
+
+所有策略参数(分位数、决策矩阵、幅度)从策略配置层传入,不在此处硬编码。
 """
 """
 
 
+import json
 import logging
 import logging
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
 
 
+import pandas as pd
+
 from agent.tools import tool
 from agent.tools import tool
 from agent.tools.models import ToolContext, ToolResult
 from agent.tools.models import ToolContext, ToolResult
 
 
 from examples.auto_put_ad.tools.ad_api import ad_update
 from examples.auto_put_ad.tools.ad_api import ad_update
 from examples.auto_put_ad.tools.data_query import _get_odps_client
 from examples.auto_put_ad.tools.data_query import _get_odps_client
+from examples.auto_put_ad.tools.strategy_config import (
+    get_strategy_config,
+    get_decision_matrix,
+    determine_strategy,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-# ===== 常量 =====
-MIN_BID = 10      # 最低出价 0.10 元(10分)
-MAX_BID = 10000   # 最高出价 100 元(10000分)
-TOP_RATIO = 0.30  # 优质广告占比(保留兼容,新逻辑用分位数)
-
-# 动作类型
-ACTION_KEEP = "keep"
-ACTION_INCREASE = "increase"
-ACTION_DECREASE = "decrease"
-ACTION_CLOSE = "close"
-ACTION_OBSERVE = "observe"
-
-
-def _determine_strategy(scale_ratio: float) -> str:
-    """根据缩量/扩量比例判断策略"""
-    if scale_ratio < 0.7:
-        return "aggressive_scale_down"
-    elif scale_ratio < 0.95:
-        return "moderate_scale_down"
-    elif scale_ratio <= 1.05:
-        return "maintain"
-    elif scale_ratio <= 1.3:
-        return "moderate_scale_up"
-    else:
-        return "aggressive_scale_up"
-
-
-def _compute_thresholds(df_valid) -> dict:
-    """基于有效广告池计算分位数阈值
-
-    Returns:
-        dict with keys: roi_p70, roi_p30, cost_p50
-    """
-    return {
-        "roi_p70": float(df_valid["efficiency"].quantile(0.70)),
-        "roi_p30": float(df_valid["efficiency"].quantile(0.30)),
-        "cost_p50": float(df_valid["cost"].quantile(0.50)),
-    }
-
-
-def _classify_ad(efficiency: float, cost: float, thresholds: dict) -> tuple:
-    """将广告分类到 ROI × 跑量 二维象限
-
-    Returns:
-        (roi_level, volume_level): e.g. ("high", "high")
-    """
-    if efficiency >= thresholds["roi_p70"]:
-        roi_level = "high"
-    elif efficiency >= thresholds["roi_p30"]:
-        roi_level = "mid"
-    else:
-        roi_level = "low"
-
-    volume_level = "high" if cost >= thresholds["cost_p50"] else "low"
-    return roi_level, volume_level
-
-
-def _decide_action(roi_level: str, volume_level: str, strategy: str) -> tuple:
-    """根据 ROI×跑量 分类 + 策略,返回 (action, adj_ratio)
-
-    三套矩阵:缩量 / 扩量 / 持平
-    """
-    # 缩量矩阵(aggressive / moderate)
-    if strategy in ("aggressive_scale_down", "moderate_scale_down"):
-        aggressive = strategy == "aggressive_scale_down"
-        matrix = {
-            ("high", "high"):  (ACTION_KEEP, 0.0),
-            ("high", "low"):   (ACTION_KEEP, 0.0),
-            ("mid", "high"):   (ACTION_DECREASE, -0.10 if aggressive else -0.05),
-            ("mid", "low"):    (ACTION_OBSERVE, 0.0),
-            ("low", "high"):   (ACTION_DECREASE, -0.15 if aggressive else -0.10),
-            ("low", "low"):    (ACTION_CLOSE, 0.0),
-        }
-        return matrix[(roi_level, volume_level)]
-
-    # 扩量矩阵(aggressive / moderate)
-    if strategy in ("aggressive_scale_up", "moderate_scale_up"):
-        aggressive = strategy == "aggressive_scale_up"
-        matrix = {
-            ("high", "high"):  (ACTION_KEEP, 0.0),
-            ("high", "low"):   (ACTION_INCREASE, 0.15 if aggressive else 0.10),
-            ("mid", "high"):   (ACTION_KEEP, 0.0),
-            ("mid", "low"):    (ACTION_INCREASE, 0.05),
-            ("low", "high"):   (ACTION_DECREASE, -0.10),
-            ("low", "low"):    (ACTION_CLOSE, 0.0),
-        }
-        return matrix[(roi_level, volume_level)]
-
-    # 持平矩阵
-    if roi_level == "low" and volume_level == "low":
-        return (ACTION_CLOSE, 0.0)
-    return (ACTION_KEEP, 0.0)
 
 
+# ===== 内部辅助函数 =====
 
 
 def _parse_bizdate(bizdate: str) -> tuple:
 def _parse_bizdate(bizdate: str) -> tuple:
     """解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)"""
     """解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)"""
@@ -124,12 +44,7 @@ def _parse_bizdate(bizdate: str) -> tuple:
 
 
 
 
 def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
 def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
-    """构建昨日效率数据 SQL(广告维度聚合)
-
-    包含冷启动保护所需字段:
-    - create_time: 广告创建时间(判定冷启动期 48h)
-    - conversions_count: 转化量(判定赔付门槛 6 次)
-    """
+    """构建昨日效率数据 SQL(广告维度聚合)"""
     return f"""
     return f"""
 SELECT
 SELECT
     a.account_id,
     a.account_id,
@@ -175,29 +90,130 @@ GROUP BY a.account_id, a.ad_id, c.ad_name, c.create_time
 """
 """
 
 
 
 
-# ===== 账户级评估 =====
+# ═════════════════════════════════════════════════════════════
+# 数据层 — 获取原始数据
+# ═════════════════════════════════════════════════════════════
 
 
-@tool(description="评估各账户昨日投放表现,输出账户健康度和稳定性标签")
-async def account_evaluate(
+@tool(description="获取指定账户昨日广告效果数据,包含效率分、消耗、转化数、冷启动信息等")
+async def get_ad_performance(
+    account_id: int = 0,
     bizdate: str = "yesterday",
     bizdate: str = "yesterday",
     context: Optional[ToolContext] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
-    账户级评估:查询各账户昨日汇总数据,按消耗量判断稳定性
+    拉取昨日广告效果数据(广告维度聚合)
 
 
     Args:
     Args:
-        bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
+        account_id: 账户ID(传 0 不过滤账户,拉全量)
+        bizdate: 业务日期,"yesterday" 或 YYYYMMDD 格式
+
+    Returns:
+        结构化广告列表,每条含: ad_id, account_id, cost, efficiency,
+        open_count, conversions_count, create_time, bid_amount, ad_status
     """
     """
-    import pandas as pd
+    try:
+        client = _get_odps_client()
+        if client is None:
+            return ToolResult(title="get_ad_performance 失败", output="ODPS 客户端未初始化")
+
+        biz, biz_dash = _parse_bizdate(bizdate)
+
+        # 拉取效率数据
+        sql_efficiency = _build_efficiency_sql(biz, biz_dash)
+        df_eff = client.execute_sql(sql_efficiency)
+        if df_eff.empty:
+            return ToolResult(title="get_ad_performance", output=f"昨日({biz})无效率数据")
 
 
+        # 按账户过滤
+        if account_id > 0:
+            df_eff = df_eff[df_eff["account_id"].astype(float).astype("Int64") == account_id]
+            if df_eff.empty:
+                return ToolResult(title="get_ad_performance", output=f"账户 {account_id} 昨日({biz})无数据")
+
+        # 拉取当前出价/状态
+        ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
+        ad_ids_str = ",".join(map(str, ad_ids))
+        sql_status = f"""
+SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
+FROM loghubods.ad_put_tencent_ad
+WHERE ad_id IN ({ad_ids_str})
+"""
+        df_status = client.execute_sql(sql_status)
+
+        # 合并
+        df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
+        df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
+        df = pd.merge(
+            df_eff,
+            df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
+            on="ad_id", how="left",
+        )
+
+        # 计算效率分
+        df["efficiency"] = df.apply(
+            lambda r: r["fission0_count"] / r["cost"]
+            if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
+            else None,
+            axis=1,
+        )
+
+        total_cost = float(df["cost"].sum())
+        avg_eff = float(df["efficiency"].mean()) if not df["efficiency"].isna().all() else 0
+
+        lines = [
+            f"广告效果数据({biz},{len(df)}条广告)",
+            f"总消耗: {total_cost:,.0f}元,平均效率分: {avg_eff:.4f}",
+            "",
+            f"{'广告ID':<12} {'账户ID':<12} {'消耗(元)':>10} {'效率分':>8} {'转化':>5} {'出价(分)':>8} {'状态':<10}",
+            "-" * 70,
+        ]
+        for _, row in df.head(20).iterrows():
+            eff_str = f"{row['efficiency']:.4f}" if pd.notna(row["efficiency"]) else "-"
+            bid_str = str(int(float(row["bid_amount"]))) if pd.notna(row.get("bid_amount")) else "-"
+            conv = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
+            lines.append(
+                f"{int(row['ad_id']):<12} {int(row['account_id']):<12} "
+                f"{row['cost']:>10,.0f} {eff_str:>8} {conv:>5} {bid_str:>8} "
+                f"{str(row.get('ad_status', '')):>10}"
+            )
+        if len(df) > 20:
+            lines.append(f"... 还有 {len(df) - 20} 条")
+
+        return ToolResult(
+            title=f"广告效果数据({len(df)}条,总消耗{total_cost:,.0f}元)",
+            output="\n".join(lines),
+            metadata={
+                "ad_data": df.to_dict("records"),
+                "total_cost": total_cost,
+                "avg_efficiency": avg_eff,
+                "ad_count": len(df),
+                "bizdate": biz,
+            },
+        )
+
+    except Exception as e:
+        logger.error("get_ad_performance 失败: %s", e, exc_info=True)
+        return ToolResult(title="get_ad_performance 失败", output=str(e))
+
+
+@tool(description="获取账户维度昨日汇总数据,包含消耗、效率、广告数、稳定性标签")
+async def get_account_summary(
+    bizdate: str = "yesterday",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    账户级汇总:查询各账户昨日汇总数据,按消耗量判断稳定性。
+
+    Args:
+        bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
+    """
     try:
     try:
         client = _get_odps_client()
         client = _get_odps_client()
         if client is None:
         if client is None:
-            return ToolResult(title="account_evaluate 失败", output="ODPS 客户端未初始化")
+            return ToolResult(title="get_account_summary 失败", output="ODPS 客户端未初始化")
 
 
         biz, biz_dash = _parse_bizdate(bizdate)
         biz, biz_dash = _parse_bizdate(bizdate)
 
 
-        # 查询各账户昨日汇总(复用效率 SQL,按 account_id 聚合)
         inner_sql = _build_efficiency_sql(biz, biz_dash)
         inner_sql = _build_efficiency_sql(biz, biz_dash)
         sql = f"""
         sql = f"""
 SELECT
 SELECT
@@ -211,15 +227,13 @@ GROUP BY account_id
 """
 """
         df = client.execute_sql(sql)
         df = client.execute_sql(sql)
         if df.empty:
         if df.empty:
-            return ToolResult(title="account_evaluate", output=f"昨日({biz})无账户数据")
+            return ToolResult(title="get_account_summary", output=f"昨日({biz})无账户数据")
 
 
-        # 计算效率分均值
         df["avg_efficiency"] = df.apply(
         df["avg_efficiency"] = df.apply(
             lambda r: r["total_fission0"] / r["total_cost"] if r["total_cost"] and r["total_cost"] > 0 else 0,
             lambda r: r["total_fission0"] / r["total_cost"] if r["total_cost"] and r["total_cost"] > 0 else 0,
             axis=1,
             axis=1,
         )
         )
 
 
-        # 按消耗量判断稳定性(中位数为阈值)
         median_cost = df["total_cost"].median()
         median_cost = df["total_cost"].median()
         p30_cost = df["total_cost"].quantile(0.30)
         p30_cost = df["total_cost"].quantile(0.30)
 
 
@@ -234,10 +248,9 @@ GROUP BY account_id
         df["stability"] = df["total_cost"].apply(label_stability)
         df["stability"] = df["total_cost"].apply(label_stability)
         df = df.sort_values("total_cost", ascending=False).reset_index(drop=True)
         df = df.sort_values("total_cost", ascending=False).reset_index(drop=True)
 
 
-        # 格式化输出
         lines = [
         lines = [
-            f"账户评估({biz},共 {len(df)} 个账户)",
-            f"消耗中位数: {median_cost:,.0f}元(≥中位数=稳定,≥P30=一般,<P30=低量)",
+            f"账户汇总({biz},共 {len(df)} 个账户)",
+            f"消耗中位数: {median_cost:,.0f}元",
             "",
             "",
             f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
             f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
             "-" * 55,
             "-" * 55,
@@ -249,7 +262,6 @@ GROUP BY account_id
                 f"{row['stability']:>6}"
                 f"{row['stability']:>6}"
             )
             )
 
 
-        # 标记建议扩量的账户
         stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
         stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
         if not stable_high_eff.empty:
         if not stable_high_eff.empty:
             lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
             lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
@@ -257,7 +269,7 @@ GROUP BY account_id
                 lines.append(f"  账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
                 lines.append(f"  账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
 
 
         return ToolResult(
         return ToolResult(
-            title=f"账户评估({len(df)}个账户)",
+            title=f"账户汇总({len(df)}个账户)",
             output="\n".join(lines),
             output="\n".join(lines),
             metadata={
             metadata={
                 "accounts": df.to_dict("records"),
                 "accounts": df.to_dict("records"),
@@ -267,184 +279,304 @@ GROUP BY account_id
         )
         )
 
 
     except Exception as e:
     except Exception as e:
-        logger.error("account_evaluate 失败: %s", e, exc_info=True)
-        return ToolResult(title="account_evaluate 失败", output=str(e))
+        logger.error("get_account_summary 失败: %s", e, exc_info=True)
+        return ToolResult(title="get_account_summary 失败", output=str(e))
 
 
 
 
-# ===== 出价调整 =====
+# ═════════════════════════════════════════════════════════════
+# 计算层 — 确定性计算,策略参数从外部传入
+# ═════════════════════════════════════════════════════════════
 
 
-@tool(description="基于昨日裂变效率数据计算今日出价调整方案(ROI×跑量二维矩阵,5种动作)")
-async def budget_calculate_from_data(
-    account_id: int,
-    total_budget_yuan: float,
-    bizdate: str = "yesterday",
-    strategy: str = "auto",
-    min_bid_cents: int = MIN_BID,
+@tool(description="计算分位数阈值(ROI高/低 + 消耗中位数),分位数参数可自定义")
+async def compute_budget_thresholds(
+    ad_data_json: str,
+    roi_high_percentile: float = 0.70,
+    roi_low_percentile: float = 0.30,
+    cost_mid_percentile: float = 0.50,
+    min_open_count: int = 100,
     context: Optional[ToolContext] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
-    智能出价调整(ROI × 跑量 二维决策矩阵):
-    1. 拉取昨日效率数据(按广告维度聚合)
-    2. 拉取当前广告出价/状态
-    3. 计算分位数阈值(ROI P70/P30 + 消耗 P50)
-    4. 每个广告分类到二维象限,决定动作(keep/increase/decrease/close/observe)
-    5. 样本不足广告跳过不操作
+    基于广告效果数据计算分位数阈值。
 
 
     Args:
     Args:
-        account_id: 账户ID(传 0 则不过滤账户
-        total_budget_yuan: 今日总预算(元
-        bizdate: 业务日期,格式 YYYYMMDD 或 "yesterday"
-        strategy: "auto" 或手动指定策略
-        min_bid_cents: 最低出价(分,默认 10)
+        ad_data_json: 广告效果数据 JSON(来自 get_ad_performance 的 metadata.ad_data)
+        roi_high_percentile: ROI 高阈值分位数(默认 P70,可调)
+        roi_low_percentile: ROI 低阈值分位数(默认 P30,可调)
+        cost_mid_percentile: 消耗中位数分位数(默认 P50,可调)
+        min_open_count: 有效广告最低打开数(默认 100)
     """
     """
-    import pandas as pd
-
     try:
     try:
-        client = _get_odps_client()
-        if client is None:
-            return ToolResult(title="budget_calculate_from_data 失败", output="ODPS 客户端未初始化")
+        ad_data = json.loads(ad_data_json) if isinstance(ad_data_json, str) else ad_data_json
+        df = pd.DataFrame(ad_data)
 
 
-        biz, biz_dash = _parse_bizdate(bizdate)
+        # 筛选有效广告
+        df_valid = df[df["open_count"] >= min_open_count].copy()
+        df_nosample = df[df["open_count"] < min_open_count]
 
 
-        # Step 1: 昨日效率数据
-        logger.info("拉取昨日效率数据: bizdate=%s", biz)
-        sql_efficiency = _build_efficiency_sql(biz, biz_dash)
-        df_eff = client.execute_sql(sql_efficiency)
-        if df_eff.empty:
-            return ToolResult(title="budget_calculate_from_data 失败", output=f"昨日({biz})效率数据为空")
+        if df_valid.empty:
+            return ToolResult(
+                title="compute_budget_thresholds",
+                output=f"无有效广告(open_count >= {min_open_count})",
+            )
 
 
-        # Step 2: 当前广告出价/状态
-        ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
-        ad_ids_str = ",".join(map(str, ad_ids))
-        sql_status = f"""
-SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
-FROM loghubods.ad_put_tencent_ad
-WHERE ad_id IN ({ad_ids_str})
-"""
-        df_status = client.execute_sql(sql_status)
+        # 确保 efficiency 列有效
+        df_valid["efficiency"] = pd.to_numeric(df_valid["efficiency"], errors="coerce")
+        df_valid = df_valid.dropna(subset=["efficiency"])
 
 
-        # Step 3: 合并
-        df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
-        df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
-        df = pd.merge(
-            df_eff,
-            df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
-            on="ad_id", how="left",
+        thresholds = {
+            "roi_high": float(df_valid["efficiency"].quantile(roi_high_percentile)),
+            "roi_low": float(df_valid["efficiency"].quantile(roi_low_percentile)),
+            "cost_mid": float(df_valid["cost"].quantile(cost_mid_percentile)),
+        }
+
+        output = (
+            f"阈值计算结果(有效广告 {len(df_valid)} 条,样本不足 {len(df_nosample)} 条):\n"
+            f"  ROI 高阈值 (P{int(roi_high_percentile*100)}): {thresholds['roi_high']:.4f}\n"
+            f"  ROI 低阈值 (P{int(roi_low_percentile*100)}): {thresholds['roi_low']:.4f}\n"
+            f"  消耗中位数 (P{int(cost_mid_percentile*100)}): {thresholds['cost_mid']:.0f}元\n"
+            f"\n分位数参数: roi_high=P{int(roi_high_percentile*100)}, "
+            f"roi_low=P{int(roi_low_percentile*100)}, "
+            f"cost_mid=P{int(cost_mid_percentile*100)}"
         )
         )
 
 
-        # Step 4: 效率分 + 有效广告筛选
-        df["efficiency"] = df.apply(
-            lambda r: r["fission0_count"] / r["cost"]
-            if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
-            else None,
-            axis=1,
+        return ToolResult(
+            title=f"阈值计算({len(df_valid)}条有效广告)",
+            output=output,
+            metadata={
+                "thresholds": thresholds,
+                "valid_count": len(df_valid),
+                "nosample_count": len(df_nosample),
+                "percentiles_used": {
+                    "roi_high": roi_high_percentile,
+                    "roi_low": roi_low_percentile,
+                    "cost_mid": cost_mid_percentile,
+                },
+            },
         )
         )
-        df_valid = df[df["open_count"] >= 100].copy().sort_values("efficiency", ascending=False).reset_index(drop=True)
-        df_nosample = df[df["open_count"] < 100].copy()
+
+    except Exception as e:
+        logger.error("compute_budget_thresholds 失败: %s", e, exc_info=True)
+        return ToolResult(title="compute_budget_thresholds 失败", output=str(e))
+
+
+@tool(description="将广告按 ROI×跑量 二维象限分类,阈值从外部传入")
+async def classify_ads(
+    ad_data_json: str,
+    thresholds_json: str,
+    min_open_count: int = 100,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    将每条广告分类到 ROI × 跑量 二维象限。
+
+    Args:
+        ad_data_json: 广告效果数据 JSON
+        thresholds_json: 阈值 JSON,如 {"roi_high": 0.05, "roi_low": 0.02, "cost_mid": 500}
+        min_open_count: 有效广告最低打开数
+    """
+    try:
+        ad_data = json.loads(ad_data_json) if isinstance(ad_data_json, str) else ad_data_json
+        thresholds = json.loads(thresholds_json) if isinstance(thresholds_json, str) else thresholds_json
+        df = pd.DataFrame(ad_data)
+
+        df_valid = df[df["open_count"] >= min_open_count].copy()
+        df_nosample = df[df["open_count"] < min_open_count]
 
 
         if df_valid.empty:
         if df_valid.empty:
-            return ToolResult(title="budget_calculate_from_data", output="无有效广告(open_count >= 100)")
+            return ToolResult(title="classify_ads", output="无有效广告")
+
+        classified = []
+        counts = {"high_high": 0, "high_low": 0, "mid_high": 0, "mid_low": 0, "low_high": 0, "low_low": 0}
+
+        for _, row in df_valid.iterrows():
+            eff = float(row["efficiency"]) if pd.notna(row.get("efficiency")) else 0.0
+            cost = float(row["cost"])
+
+            if eff >= thresholds["roi_high"]:
+                roi_level = "high"
+            elif eff >= thresholds["roi_low"]:
+                roi_level = "mid"
+            else:
+                roi_level = "low"
+
+            volume_level = "high" if cost >= thresholds["cost_mid"] else "low"
+            quadrant = f"{roi_level}_{volume_level}"
+            counts[quadrant] = counts.get(quadrant, 0) + 1
 
 
-        # Step 5: 计算分位数阈值
-        thresholds = _compute_thresholds(df_valid)
+            item = row.to_dict()
+            item["roi_level"] = roi_level
+            item["volume_level"] = volume_level
+            item["quadrant"] = quadrant
+            classified.append(item)
 
 
-        # Step 6: 判断策略
-        yesterday_total = float(df_valid["cost"].sum())
-        scale_ratio = total_budget_yuan / yesterday_total if yesterday_total > 0 else 1.0
-        if strategy == "auto":
-            strategy = _determine_strategy(scale_ratio)
+        lines = [
+            f"广告分类结果({len(classified)}条有效,{len(df_nosample)}条样本不足)",
+            f"阈值: ROI高={thresholds['roi_high']:.4f}, ROI低={thresholds['roi_low']:.4f}, 消耗中位={thresholds['cost_mid']:.0f}元",
+            "",
+            "分布:",
+        ]
+        for q, c in counts.items():
+            roi, vol = q.split("_")
+            lines.append(f"  {roi}ROI + {vol}跑量: {c}条")
+
+        return ToolResult(
+            title=f"广告分类({len(classified)}条)",
+            output="\n".join(lines),
+            metadata={
+                "classified_ads": classified,
+                "nosample_ads": df_nosample.to_dict("records"),
+                "distribution": counts,
+                "thresholds_used": thresholds,
+            },
+        )
+
+    except Exception as e:
+        logger.error("classify_ads 失败: %s", e, exc_info=True)
+        return ToolResult(title="classify_ads 失败", output=str(e))
+
+
+@tool(description="计算出价调整方案,策略参数(决策矩阵、幅度、保护规则)全部从外部传入")
+async def compute_bid_adjustment(
+    classified_ads_json: str,
+    strategy: str,
+    decision_matrix_json: str = "",
+    max_increase_pct: float = 0.15,
+    max_decrease_pct: float = -0.15,
+    protect_cold_start: bool = True,
+    cold_start_hours: int = 48,
+    cold_start_min_conversions: int = 6,
+    protect_compensation: bool = True,
+    compensation_min_conversions: int = 6,
+    min_bid: int = 10,
+    max_bid: int = 10000,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    根据分类结果和策略参数,计算每条广告的出价调整方案。
+
+    Args:
+        classified_ads_json: 分类后的广告数据 JSON(来自 classify_ads)
+        strategy: 策略名称(aggressive_scale_down / moderate_scale_down / maintain / moderate_scale_up / aggressive_scale_up)
+        decision_matrix_json: 决策矩阵 JSON。为空则从配置层加载。
+            格式: {"high_high": ["keep", 0.0], "mid_high": ["decrease", -0.05], ...}
+        max_increase_pct: 最大提价幅度(默认 0.15 = +15%)
+        max_decrease_pct: 最大降价幅度(默认 -0.15 = -15%)
+        protect_cold_start: 是否启用冷启动保护
+        cold_start_hours: 冷启动期小时数
+        cold_start_min_conversions: 冷启动最少转化数
+        protect_compensation: 是否启用赔付保护
+        compensation_min_conversions: 赔付门槛转化数
+        min_bid: 最低出价(分)
+        max_bid: 最高出价(分)
+    """
+    try:
+        classified_ads = json.loads(classified_ads_json) if isinstance(classified_ads_json, str) else classified_ads_json
+
+        # 加载决策矩阵
+        if decision_matrix_json:
+            matrix = json.loads(decision_matrix_json) if isinstance(decision_matrix_json, str) else decision_matrix_json
+            # 转为 tuple
+            matrix = {k: tuple(v) for k, v in matrix.items()}
+        else:
+            config = get_strategy_config()
+            matrix = get_decision_matrix(config, strategy)
 
 
-        # Step 7: 二维矩阵决策(含冷启动保护)
         results = []
         results = []
         cold_start_count = 0
         cold_start_count = 0
-        for _, row in df_valid.iterrows():
-            eff = float(row["efficiency"]) if pd.notna(row["efficiency"]) else 0.0
-            cost = float(row["cost"])
-            conversions = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
 
 
-            # --- 冷启动保护判定(优先于决策矩阵) ---
+        for ad in classified_ads:
+            conversions = int(ad.get("conversions_count", 0) or 0)
+            quadrant = ad.get("quadrant", f"{ad.get('roi_level', 'mid')}_{ad.get('volume_level', 'low')}")
+
+            # --- 冷启动保护 ---
             is_cold_start = False
             is_cold_start = False
             cold_start_reason = ""
             cold_start_reason = ""
 
 
-            # 判定1:广告创建时间 < 48 小时
-            create_time = row.get("create_time")
-            if create_time and pd.notna(create_time):
-                try:
-                    if isinstance(create_time, str):
-                        ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
-                    else:
-                        ct = pd.Timestamp(create_time).to_pydatetime()
-                    hours_since_creation = (datetime.now() - ct).total_seconds() / 3600
-                    if hours_since_creation < 48:
-                        is_cold_start = True
-                        cold_start_reason = f"冷启动期({hours_since_creation:.0f}h<48h)"
-                except (ValueError, TypeError):
-                    pass
-
-            # 判定2:转化数不足 < 6
-            if conversions < 6 and not is_cold_start:
-                is_cold_start = True
-                cold_start_reason = f"转化不足({conversions}<6)"
+            if protect_cold_start:
+                create_time = ad.get("create_time")
+                if create_time and pd.notna(create_time):
+                    try:
+                        if isinstance(create_time, str):
+                            ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
+                        else:
+                            ct = pd.Timestamp(create_time).to_pydatetime()
+                        hours_since = (datetime.now() - ct).total_seconds() / 3600
+                        if hours_since < cold_start_hours:
+                            is_cold_start = True
+                            cold_start_reason = f"冷启动期({hours_since:.0f}h<{cold_start_hours}h)"
+                    except (ValueError, TypeError):
+                        pass
+
+                if conversions < cold_start_min_conversions and not is_cold_start:
+                    is_cold_start = True
+                    cold_start_reason = f"转化不足({conversions}<{cold_start_min_conversions})"
 
 
             if is_cold_start:
             if is_cold_start:
                 cold_start_count += 1
                 cold_start_count += 1
-                roi_level, volume_level = _classify_ad(eff, cost, thresholds)
-                action = ACTION_OBSERVE
-                adj_ratio = 0.0
+                action, adj_ratio = "observe", 0.0
             else:
             else:
-                roi_level, volume_level = _classify_ad(eff, cost, thresholds)
-                action, adj_ratio = _decide_action(roi_level, volume_level, strategy)
-
-                # --- 关停保护:赔付门槛检查 ---
-                if action == ACTION_CLOSE and 3 <= conversions < 6:
-                    action = ACTION_OBSERVE
-                    adj_ratio = 0.0
-                    cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到6)"
-
-            bid = row["bid_amount"] if pd.notna(row["bid_amount"]) else None
+                action, adj_ratio = matrix.get(quadrant, ("keep", 0.0))
+
+                # 限制幅度范围
+                if adj_ratio > 0:
+                    adj_ratio = min(adj_ratio, max_increase_pct)
+                elif adj_ratio < 0:
+                    adj_ratio = max(adj_ratio, max_decrease_pct)
+
+                # --- 赔付保护 ---
+                if protect_compensation and action == "close":
+                    if 3 <= conversions < compensation_min_conversions:
+                        action = "observe"
+                        adj_ratio = 0.0
+                        cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到{compensation_min_conversions})"
+
+            # 计算新出价
+            bid = ad.get("bid_amount")
+            bid_val = float(bid) if bid and pd.notna(bid) else None
             new_bid = None
             new_bid = None
-            if bid and action in (ACTION_INCREASE, ACTION_DECREASE):
-                new_bid = max(min_bid_cents, min(MAX_BID, int(float(bid) * (1 + adj_ratio))))
-            elif bid:
-                new_bid = int(float(bid))  # keep/observe/close 不改出价
+            if bid_val and action in ("increase", "decrease"):
+                new_bid = max(min_bid, min(max_bid, int(bid_val * (1 + adj_ratio))))
+            elif bid_val:
+                new_bid = int(bid_val)
 
 
             results.append({
             results.append({
                 "date": datetime.now().strftime("%Y-%m-%d"),
                 "date": datetime.now().strftime("%Y-%m-%d"),
-                "ad_id": int(row["ad_id"]),
-                "ad_name": str(row["ad_name"]) if pd.notna(row["ad_name"]) else "",
-                "account_id": int(row["account_id"]) if pd.notna(row["account_id"]) else 0,
-                "roi_level": roi_level,
-                "volume_level": volume_level,
-                "efficiency": round(eff, 4),
-                "cost": round(cost, 2),
-                "open_count": int(row["open_count"]),
+                "ad_id": int(ad.get("ad_id", 0)),
+                "ad_name": str(ad.get("ad_name", "")),
+                "account_id": int(ad.get("account_id", 0)),
+                "roi_level": ad.get("roi_level", ""),
+                "volume_level": ad.get("volume_level", ""),
+                "quadrant": quadrant,
+                "efficiency": round(float(ad.get("efficiency", 0) or 0), 4),
+                "cost": round(float(ad.get("cost", 0)), 2),
+                "open_count": int(ad.get("open_count", 0)),
                 "conversions_count": conversions,
                 "conversions_count": conversions,
                 "is_cold_start": is_cold_start,
                 "is_cold_start": is_cold_start,
                 "cold_start_reason": cold_start_reason,
                 "cold_start_reason": cold_start_reason,
-                "current_bid": int(float(bid)) if bid else None,
+                "current_bid": int(bid_val) if bid_val else None,
                 "new_bid": new_bid,
                 "new_bid": new_bid,
-                "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "",
+                "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "-",
                 "action": action,
                 "action": action,
-                "ad_status": str(row["ad_status"]) if pd.notna(row.get("ad_status")) else "",
+                "ad_status": str(ad.get("ad_status", "")),
             })
             })
 
 
-        # Step 8: 格式化输出(按动作分组)
-        direction = "缩量" if scale_ratio < 1 else "扩量" if scale_ratio > 1 else "持平"
+        # 汇总统计
+        action_counts = {}
+        for r in results:
+            action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
+
+        action_labels = [
+            ("keep", "保持不动"), ("increase", "提价放量"), ("decrease", "降价控量"),
+            ("close", "建议关停"), ("observe", "观察不动"),
+        ]
+
         lines = [
         lines = [
-            f"出价调整方案({direction} {abs(1-scale_ratio)*100:.0f}%)",
-            f"昨日消耗: {yesterday_total:,.0f} 元 → 今日预算: {total_budget_yuan:,.0f} 元",
+            f"出价调整方案({len(results)}条广告)",
             f"策略: {strategy}",
             f"策略: {strategy}",
-            f"阈值: ROI P70={thresholds['roi_p70']:.4f}, P30={thresholds['roi_p30']:.4f}, 消耗 P50={thresholds['cost_p50']:.0f}元",
             "",
             "",
         ]
         ]
 
 
-        action_labels = [
-            (ACTION_KEEP, "保持不动"),
-            (ACTION_INCREASE, "提价放量"),
-            (ACTION_DECREASE, "降价控量"),
-            (ACTION_CLOSE, "建议关停"),
-            (ACTION_OBSERVE, "观察不动"),
-        ]
         for act, label in action_labels:
         for act, label in action_labels:
             sub = [r for r in results if r["action"] == act]
             sub = [r for r in results if r["action"] == act]
             if not sub:
             if not sub:
@@ -460,109 +592,52 @@ WHERE ad_id IN ({ad_ids_str})
                 lines.append(f"  ... 还有 {len(sub)-5} 个")
                 lines.append(f"  ... 还有 {len(sub)-5} 个")
             lines.append("")
             lines.append("")
 
 
-        if len(df_nosample) > 0:
-            lines.append(f"【样本不足 - {len(df_nosample)}个,本次不操作】")
-            lines.append("")
-
         if cold_start_count > 0:
         if cold_start_count > 0:
-            cold_start_ads = [r for r in results if r.get("is_cold_start")]
-            lines.append(f"【冷启动保护 - {cold_start_count}个,标记observe不调价】")
-            for item in cold_start_ads[:5]:
-                lines.append(f"  {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']} | 消耗:{item['cost']:.0f}元")
+            cold_ads = [r for r in results if r["is_cold_start"]]
+            lines.append(f"【冷启动保护 - {cold_start_count}个】")
+            for item in cold_ads[:5]:
+                lines.append(f"  {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']}")
             if cold_start_count > 5:
             if cold_start_count > 5:
                 lines.append(f"  ... 还有 {cold_start_count - 5} 个")
                 lines.append(f"  ... 还有 {cold_start_count - 5} 个")
             lines.append("")
             lines.append("")
 
 
-        # 汇总
-        action_counts = {}
-        for r in results:
-            action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
         summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
         summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
-        lines.append(f"合计:{' / '.join(summary_parts)} / 样本不足:{len(df_nosample)} / 冷启动保护:{cold_start_count}")
-
-        # Step 9: 输出 Excel(按动作颜色标识)
-        try:
-            import openpyxl
-            from openpyxl.styles import PatternFill, Font
-
-            ACTION_COLORS = {
-                "increase": "C6EFCE",  # 绿
-                "decrease": "FFEB9C",  # 橙黄
-                "close":    "FFC7CE",  # 红
-                "observe":  "FFFF99",  # 黄
-                "keep":     "FFFFFF",  # 白
-            }
-
-            output_dir = Path(__file__).parent.parent / "outputs"
-            output_dir.mkdir(exist_ok=True)
-            xlsx_path = output_dir / f"adjustment_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
-
-            headers_cn = ["日期", "账户ID", "广告ID", "广告名称", "动作", "当前出价(分)", "新出价(分)",
-                          "调整幅度", "ROI等级", "跑量等级", "效率分", "昨日消耗(元)", "打开数",
-                          "转化数", "冷启动", "冷启动原因", "广告状态"]
-            fields_en = ["date", "account_id", "ad_id", "ad_name", "action", "current_bid", "new_bid",
-                         "adjustment_ratio", "roi_level", "volume_level", "efficiency", "cost",
-                         "open_count", "conversions_count", "is_cold_start", "cold_start_reason", "ad_status"]
-
-            wb = openpyxl.Workbook()
-            ws = wb.active
-            ws.title = "调整方案"
-
-            # 表头(加粗)
-            ws.append(headers_cn)
-            for cell in ws[1]:
-                cell.font = Font(bold=True)
-
-            # 数据行 + 颜色
-            for row in results:
-                ws.append([row.get(f) for f in fields_en])
-                color = ACTION_COLORS.get(row.get("action", "keep"), "FFFFFF")
-                fill = PatternFill(fill_type="solid", fgColor=color)
-                for cell in ws[ws.max_row]:
-                    cell.fill = fill
-
-            # 冻结首行 + 列宽
-            ws.freeze_panes = "A2"
-            for col in ws.columns:
-                ws.column_dimensions[col[0].column_letter].width = 16
-
-            wb.save(xlsx_path)
-            lines.append(f"\n📄 完整方案已输出: {xlsx_path}")
-            logger.info("方案已保存: %s", xlsx_path)
-        except Exception as xlsx_err:
-            logger.warning("xlsx 输出失败(不影响主流程): %s", xlsx_err)
+        lines.append(f"合计: {' / '.join(summary_parts)} / 冷启动保护:{cold_start_count}")
 
 
         return ToolResult(
         return ToolResult(
-            title=f"出价调整方案({len(results)}个广告,{direction}{abs(1-scale_ratio)*100:.0f}%)",
+            title=f"出价调整方案({len(results)}条,{strategy})",
             output="\n".join(lines),
             output="\n".join(lines),
             metadata={
             metadata={
                 "adjustment_plan": results,
                 "adjustment_plan": results,
                 "strategy": strategy,
                 "strategy": strategy,
-                "scale_ratio": scale_ratio,
-                "thresholds": thresholds,
-                "yesterday_total": yesterday_total,
-                "total_budget_yuan": total_budget_yuan,
-                "nosample_count": len(df_nosample),
-                "cold_start_count": cold_start_count,
                 "action_counts": action_counts,
                 "action_counts": action_counts,
+                "cold_start_count": cold_start_count,
+                "params": {
+                    "max_increase_pct": max_increase_pct,
+                    "max_decrease_pct": max_decrease_pct,
+                    "protect_cold_start": protect_cold_start,
+                    "protect_compensation": protect_compensation,
+                },
             },
             },
         )
         )
 
 
     except Exception as e:
     except Exception as e:
-        logger.error("budget_calculate_from_data 失败: %s", e, exc_info=True)
-        return ToolResult(title="budget_calculate_from_data 失败", output=str(e))
+        logger.error("compute_bid_adjustment 失败: %s", e, exc_info=True)
+        return ToolResult(title="compute_bid_adjustment 失败", output=str(e))
 
 
 
 
-# ===== 执行出价调整 =====
+# ═════════════════════════════════════════════════════════════
+# 执行层 — 调用 API 执行调整
+# ═════════════════════════════════════════════════════════════
 
 
-@tool(description="执行出价调整方案")
+@tool(description="执行出价调整方案,批量调用 API 修改出价")
 async def bid_adjustment_execute(
 async def bid_adjustment_execute(
     adjustment_plan: List[Dict],
     adjustment_plan: List[Dict],
     account_id: int,
     account_id: int,
     context: Optional[ToolContext] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
-    批量执行出价调整
+    批量执行出价调整。
 
 
     Args:
     Args:
         adjustment_plan: 调整方案列表,每项包含 ad_id, new_bid, action
         adjustment_plan: 调整方案列表,每项包含 ad_id, new_bid, action
@@ -606,3 +681,12 @@ async def bid_adjustment_execute(
         title="出价调整执行结果",
         title="出价调整执行结果",
         output="\n".join(output_lines),
         output="\n".join(output_lines),
     )
     )
+
+
+# ═════════════════════════════════════════════════════════════
+# 兼容层 — 保留旧接口,内部调用新工具链
+# ═════════════════════════════════════════════════════════════
+
+# 保留旧名称引用,避免 config.py 中的工具白名单报错
+account_evaluate = get_account_summary
+budget_calculate_from_data = None  # 已废弃,功能拆分到 get_ad_performance + compute_* 工具链

+ 608 - 0
examples/auto_put_ad/tools/budget_calc.py.backup

@@ -0,0 +1,608 @@
+"""
+预算计算引擎 — 出价调整与账户评估
+
+核心机制:通过调整 oCPM 出价(bid_amount)控制消耗速度,不设日预算限制。
+决策矩阵:ROI × 跑量 二维分类,5 种动作(keep/increase/decrease/close/observe)。
+缩量、扩量、持平各一套矩阵,调整幅度 5%-15%。
+"""
+
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+from examples.auto_put_ad.tools.ad_api import ad_update
+from examples.auto_put_ad.tools.data_query import _get_odps_client
+
+logger = logging.getLogger(__name__)
+
+# ===== 常量 =====
+MIN_BID = 10      # 最低出价 0.10 元(10分)
+MAX_BID = 10000   # 最高出价 100 元(10000分)
+TOP_RATIO = 0.30  # 优质广告占比(保留兼容,新逻辑用分位数)
+
+# 动作类型
+ACTION_KEEP = "keep"
+ACTION_INCREASE = "increase"
+ACTION_DECREASE = "decrease"
+ACTION_CLOSE = "close"
+ACTION_OBSERVE = "observe"
+
+
+def _determine_strategy(scale_ratio: float) -> str:
+    """根据缩量/扩量比例判断策略"""
+    if scale_ratio < 0.7:
+        return "aggressive_scale_down"
+    elif scale_ratio < 0.95:
+        return "moderate_scale_down"
+    elif scale_ratio <= 1.05:
+        return "maintain"
+    elif scale_ratio <= 1.3:
+        return "moderate_scale_up"
+    else:
+        return "aggressive_scale_up"
+
+
+def _compute_thresholds(df_valid) -> dict:
+    """基于有效广告池计算分位数阈值
+
+    Returns:
+        dict with keys: roi_p70, roi_p30, cost_p50
+    """
+    return {
+        "roi_p70": float(df_valid["efficiency"].quantile(0.70)),
+        "roi_p30": float(df_valid["efficiency"].quantile(0.30)),
+        "cost_p50": float(df_valid["cost"].quantile(0.50)),
+    }
+
+
+def _classify_ad(efficiency: float, cost: float, thresholds: dict) -> tuple:
+    """将广告分类到 ROI × 跑量 二维象限
+
+    Returns:
+        (roi_level, volume_level): e.g. ("high", "high")
+    """
+    if efficiency >= thresholds["roi_p70"]:
+        roi_level = "high"
+    elif efficiency >= thresholds["roi_p30"]:
+        roi_level = "mid"
+    else:
+        roi_level = "low"
+
+    volume_level = "high" if cost >= thresholds["cost_p50"] else "low"
+    return roi_level, volume_level
+
+
+def _decide_action(roi_level: str, volume_level: str, strategy: str) -> tuple:
+    """根据 ROI×跑量 分类 + 策略,返回 (action, adj_ratio)
+
+    三套矩阵:缩量 / 扩量 / 持平
+    """
+    # 缩量矩阵(aggressive / moderate)
+    if strategy in ("aggressive_scale_down", "moderate_scale_down"):
+        aggressive = strategy == "aggressive_scale_down"
+        matrix = {
+            ("high", "high"):  (ACTION_KEEP, 0.0),
+            ("high", "low"):   (ACTION_KEEP, 0.0),
+            ("mid", "high"):   (ACTION_DECREASE, -0.10 if aggressive else -0.05),
+            ("mid", "low"):    (ACTION_OBSERVE, 0.0),
+            ("low", "high"):   (ACTION_DECREASE, -0.15 if aggressive else -0.10),
+            ("low", "low"):    (ACTION_CLOSE, 0.0),
+        }
+        return matrix[(roi_level, volume_level)]
+
+    # 扩量矩阵(aggressive / moderate)
+    if strategy in ("aggressive_scale_up", "moderate_scale_up"):
+        aggressive = strategy == "aggressive_scale_up"
+        matrix = {
+            ("high", "high"):  (ACTION_KEEP, 0.0),
+            ("high", "low"):   (ACTION_INCREASE, 0.15 if aggressive else 0.10),
+            ("mid", "high"):   (ACTION_KEEP, 0.0),
+            ("mid", "low"):    (ACTION_INCREASE, 0.05),
+            ("low", "high"):   (ACTION_DECREASE, -0.10),
+            ("low", "low"):    (ACTION_CLOSE, 0.0),
+        }
+        return matrix[(roi_level, volume_level)]
+
+    # 持平矩阵
+    if roi_level == "low" and volume_level == "low":
+        return (ACTION_CLOSE, 0.0)
+    return (ACTION_KEEP, 0.0)
+
+
+def _parse_bizdate(bizdate: str) -> tuple:
+    """解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)"""
+    if bizdate in ("yesterday", ""):
+        biz = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    else:
+        biz = bizdate.replace("-", "")
+    biz_dash = f"{biz[:4]}-{biz[4:6]}-{biz[6:]}"
+    return biz, biz_dash
+
+
+def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
+    """构建昨日效率数据 SQL(广告维度聚合)
+
+    包含冷启动保护所需字段:
+    - create_time: 广告创建时间(判定冷启动期 48h)
+    - conversions_count: 转化量(判定赔付门槛 6 次)
+    """
+    return f"""
+SELECT
+    a.account_id,
+    a.ad_id,
+    c.ad_name,
+    c.create_time,
+    SUM(b.cost/100)          AS cost,
+    SUM(b.valid_click_count) AS valid_click_count,
+    SUM(b.conversions_count) AS conversions_count,
+    SUM(t.首层小程序打开数)   AS open_count,
+    SUM(t.裂变0层回流数)     AS fission0_count,
+    SUM(t.总回流人数)        AS total_return_count
+FROM (
+    SELECT
+        IF(c.creative_name IS NOT NULL, c.creative_name, t.rootsourceid) AS creative_name,
+        t.*
+    FROM loghubods.touliu_data t
+    LEFT JOIN (
+        SELECT DISTINCT creative_name,
+            SPLIT(GET_JSON_OBJECT(page_spec,'$.wechat_mini_program_spec.mini_program_path'),'rootSourceId%3D')[1] AS rootsourceid
+        FROM loghubods.ad_put_tencent_creative_components a
+        LEFT JOIN loghubods.ad_put_tencent_creative_day b ON a.creative_id = b.creative_id
+        WHERE page_type = 'PAGE_TYPE_WECHAT_MINI_PROGRAM'
+    ) c ON c.rootsourceid = t.rootsourceid
+    WHERE t.dt = '{biz}'
+) t
+LEFT JOIN loghubods.ad_put_tencent_creative_day a ON t.creative_name = a.creative_name
+LEFT JOIN loghubods.ad_put_tencent_ad c ON a.ad_id = c.ad_id
+LEFT JOIN (
+    SELECT creative_id, valid_click_count, cost, conversions_count
+    FROM (
+        SELECT creative_id, valid_click_count, cost, conversions_count,
+            ROW_NUMBER() OVER (PARTITION BY creative_id ORDER BY update_time DESC) AS rank
+        FROM loghubods.ad_put_tencent_creative_data_day
+        WHERE dt = '{biz_dash}'
+    ) t WHERE rank = 1
+) b ON a.creative_id = b.creative_id
+WHERE t.dt = '{biz}'
+  AND a.ad_id IS NOT NULL
+  AND b.cost IS NOT NULL
+  AND b.cost > 0
+GROUP BY a.account_id, a.ad_id, c.ad_name, c.create_time
+"""
+
+
+# ===== 账户级评估 =====
+
+@tool(description="评估各账户昨日投放表现,输出账户健康度和稳定性标签")
+async def account_evaluate(
+    bizdate: str = "yesterday",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    账户级评估:查询各账户昨日汇总数据,按消耗量判断稳定性。
+
+    Args:
+        bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
+    """
+    import pandas as pd
+
+    try:
+        client = _get_odps_client()
+        if client is None:
+            return ToolResult(title="account_evaluate 失败", output="ODPS 客户端未初始化")
+
+        biz, biz_dash = _parse_bizdate(bizdate)
+
+        # 查询各账户昨日汇总(复用效率 SQL,按 account_id 聚合)
+        inner_sql = _build_efficiency_sql(biz, biz_dash)
+        sql = f"""
+SELECT
+    account_id,
+    COUNT(DISTINCT ad_id) AS ad_count,
+    SUM(cost)             AS total_cost,
+    SUM(open_count)       AS total_open,
+    SUM(fission0_count)   AS total_fission0
+FROM ({inner_sql}) t
+GROUP BY account_id
+"""
+        df = client.execute_sql(sql)
+        if df.empty:
+            return ToolResult(title="account_evaluate", output=f"昨日({biz})无账户数据")
+
+        # 计算效率分均值
+        df["avg_efficiency"] = df.apply(
+            lambda r: r["total_fission0"] / r["total_cost"] if r["total_cost"] and r["total_cost"] > 0 else 0,
+            axis=1,
+        )
+
+        # 按消耗量判断稳定性(中位数为阈值)
+        median_cost = df["total_cost"].median()
+        p30_cost = df["total_cost"].quantile(0.30)
+
+        def label_stability(cost):
+            if cost >= median_cost:
+                return "稳定"
+            elif cost >= p30_cost:
+                return "一般"
+            else:
+                return "低量"
+
+        df["stability"] = df["total_cost"].apply(label_stability)
+        df = df.sort_values("total_cost", ascending=False).reset_index(drop=True)
+
+        # 格式化输出
+        lines = [
+            f"账户评估({biz},共 {len(df)} 个账户)",
+            f"消耗中位数: {median_cost:,.0f}元(≥中位数=稳定,≥P30=一般,<P30=低量)",
+            "",
+            f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
+            "-" * 55,
+        ]
+        for _, row in df.iterrows():
+            lines.append(
+                f"{int(row['account_id']):<15} {int(row['ad_count']):>6} "
+                f"{row['total_cost']:>12,.0f} {row['avg_efficiency']:>10.4f} "
+                f"{row['stability']:>6}"
+            )
+
+        # 标记建议扩量的账户
+        stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
+        if not stable_high_eff.empty:
+            lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
+            for _, row in stable_high_eff.iterrows():
+                lines.append(f"  账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
+
+        return ToolResult(
+            title=f"账户评估({len(df)}个账户)",
+            output="\n".join(lines),
+            metadata={
+                "accounts": df.to_dict("records"),
+                "median_cost": median_cost,
+                "bizdate": biz,
+            },
+        )
+
+    except Exception as e:
+        logger.error("account_evaluate 失败: %s", e, exc_info=True)
+        return ToolResult(title="account_evaluate 失败", output=str(e))
+
+
+# ===== 出价调整 =====
+
+@tool(description="基于昨日裂变效率数据计算今日出价调整方案(ROI×跑量二维矩阵,5种动作)")
+async def budget_calculate_from_data(
+    account_id: int,
+    total_budget_yuan: float,
+    bizdate: str = "yesterday",
+    strategy: str = "auto",
+    min_bid_cents: int = MIN_BID,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    智能出价调整(ROI × 跑量 二维决策矩阵):
+    1. 拉取昨日效率数据(按广告维度聚合)
+    2. 拉取当前广告出价/状态
+    3. 计算分位数阈值(ROI P70/P30 + 消耗 P50)
+    4. 每个广告分类到二维象限,决定动作(keep/increase/decrease/close/observe)
+    5. 样本不足广告跳过不操作
+
+    Args:
+        account_id: 账户ID(传 0 则不过滤账户)
+        total_budget_yuan: 今日总预算(元)
+        bizdate: 业务日期,格式 YYYYMMDD 或 "yesterday"
+        strategy: "auto" 或手动指定策略
+        min_bid_cents: 最低出价(分,默认 10)
+    """
+    import pandas as pd
+
+    try:
+        client = _get_odps_client()
+        if client is None:
+            return ToolResult(title="budget_calculate_from_data 失败", output="ODPS 客户端未初始化")
+
+        biz, biz_dash = _parse_bizdate(bizdate)
+
+        # Step 1: 昨日效率数据
+        logger.info("拉取昨日效率数据: bizdate=%s", biz)
+        sql_efficiency = _build_efficiency_sql(biz, biz_dash)
+        df_eff = client.execute_sql(sql_efficiency)
+        if df_eff.empty:
+            return ToolResult(title="budget_calculate_from_data 失败", output=f"昨日({biz})效率数据为空")
+
+        # Step 2: 当前广告出价/状态
+        ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
+        ad_ids_str = ",".join(map(str, ad_ids))
+        sql_status = f"""
+SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
+FROM loghubods.ad_put_tencent_ad
+WHERE ad_id IN ({ad_ids_str})
+"""
+        df_status = client.execute_sql(sql_status)
+
+        # Step 3: 合并
+        df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
+        df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
+        df = pd.merge(
+            df_eff,
+            df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
+            on="ad_id", how="left",
+        )
+
+        # Step 4: 效率分 + 有效广告筛选
+        df["efficiency"] = df.apply(
+            lambda r: r["fission0_count"] / r["cost"]
+            if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
+            else None,
+            axis=1,
+        )
+        df_valid = df[df["open_count"] >= 100].copy().sort_values("efficiency", ascending=False).reset_index(drop=True)
+        df_nosample = df[df["open_count"] < 100].copy()
+
+        if df_valid.empty:
+            return ToolResult(title="budget_calculate_from_data", output="无有效广告(open_count >= 100)")
+
+        # Step 5: 计算分位数阈值
+        thresholds = _compute_thresholds(df_valid)
+
+        # Step 6: 判断策略
+        yesterday_total = float(df_valid["cost"].sum())
+        scale_ratio = total_budget_yuan / yesterday_total if yesterday_total > 0 else 1.0
+        if strategy == "auto":
+            strategy = _determine_strategy(scale_ratio)
+
+        # Step 7: 二维矩阵决策(含冷启动保护)
+        results = []
+        cold_start_count = 0
+        for _, row in df_valid.iterrows():
+            eff = float(row["efficiency"]) if pd.notna(row["efficiency"]) else 0.0
+            cost = float(row["cost"])
+            conversions = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
+
+            # --- 冷启动保护判定(优先于决策矩阵) ---
+            is_cold_start = False
+            cold_start_reason = ""
+
+            # 判定1:广告创建时间 < 48 小时
+            create_time = row.get("create_time")
+            if create_time and pd.notna(create_time):
+                try:
+                    if isinstance(create_time, str):
+                        ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
+                    else:
+                        ct = pd.Timestamp(create_time).to_pydatetime()
+                    hours_since_creation = (datetime.now() - ct).total_seconds() / 3600
+                    if hours_since_creation < 48:
+                        is_cold_start = True
+                        cold_start_reason = f"冷启动期({hours_since_creation:.0f}h<48h)"
+                except (ValueError, TypeError):
+                    pass
+
+            # 判定2:转化数不足 < 6
+            if conversions < 6 and not is_cold_start:
+                is_cold_start = True
+                cold_start_reason = f"转化不足({conversions}<6)"
+
+            if is_cold_start:
+                cold_start_count += 1
+                roi_level, volume_level = _classify_ad(eff, cost, thresholds)
+                action = ACTION_OBSERVE
+                adj_ratio = 0.0
+            else:
+                roi_level, volume_level = _classify_ad(eff, cost, thresholds)
+                action, adj_ratio = _decide_action(roi_level, volume_level, strategy)
+
+                # --- 关停保护:赔付门槛检查 ---
+                if action == ACTION_CLOSE and 3 <= conversions < 6:
+                    action = ACTION_OBSERVE
+                    adj_ratio = 0.0
+                    cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到6)"
+
+            bid = row["bid_amount"] if pd.notna(row["bid_amount"]) else None
+            new_bid = None
+            if bid and action in (ACTION_INCREASE, ACTION_DECREASE):
+                new_bid = max(min_bid_cents, min(MAX_BID, int(float(bid) * (1 + adj_ratio))))
+            elif bid:
+                new_bid = int(float(bid))  # keep/observe/close 不改出价
+
+            results.append({
+                "date": datetime.now().strftime("%Y-%m-%d"),
+                "ad_id": int(row["ad_id"]),
+                "ad_name": str(row["ad_name"]) if pd.notna(row["ad_name"]) else "",
+                "account_id": int(row["account_id"]) if pd.notna(row["account_id"]) else 0,
+                "roi_level": roi_level,
+                "volume_level": volume_level,
+                "efficiency": round(eff, 4),
+                "cost": round(cost, 2),
+                "open_count": int(row["open_count"]),
+                "conversions_count": conversions,
+                "is_cold_start": is_cold_start,
+                "cold_start_reason": cold_start_reason,
+                "current_bid": int(float(bid)) if bid else None,
+                "new_bid": new_bid,
+                "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "—",
+                "action": action,
+                "ad_status": str(row["ad_status"]) if pd.notna(row.get("ad_status")) else "",
+            })
+
+        # Step 8: 格式化输出(按动作分组)
+        direction = "缩量" if scale_ratio < 1 else "扩量" if scale_ratio > 1 else "持平"
+        lines = [
+            f"出价调整方案({direction} {abs(1-scale_ratio)*100:.0f}%)",
+            f"昨日消耗: {yesterday_total:,.0f} 元 → 今日预算: {total_budget_yuan:,.0f} 元",
+            f"策略: {strategy}",
+            f"阈值: ROI P70={thresholds['roi_p70']:.4f}, P30={thresholds['roi_p30']:.4f}, 消耗 P50={thresholds['cost_p50']:.0f}元",
+            "",
+        ]
+
+        action_labels = [
+            (ACTION_KEEP, "保持不动"),
+            (ACTION_INCREASE, "提价放量"),
+            (ACTION_DECREASE, "降价控量"),
+            (ACTION_CLOSE, "建议关停"),
+            (ACTION_OBSERVE, "观察不动"),
+        ]
+        for act, label in action_labels:
+            sub = [r for r in results if r["action"] == act]
+            if not sub:
+                continue
+            lines.append(f"【{label}({act})- {len(sub)}个】")
+            for item in sub[:5]:
+                bid_info = f"出价:{item['current_bid']}→{item['new_bid']}分 {item['adjustment_ratio']}" if item["current_bid"] else "无出价"
+                lines.append(
+                    f"  {item['ad_id']} | ROI:{item['roi_level']}/量:{item['volume_level']} | "
+                    f"效率:{item['efficiency']} | 消耗:{item['cost']:.0f}元 | {bid_info}"
+                )
+            if len(sub) > 5:
+                lines.append(f"  ... 还有 {len(sub)-5} 个")
+            lines.append("")
+
+        if len(df_nosample) > 0:
+            lines.append(f"【样本不足 - {len(df_nosample)}个,本次不操作】")
+            lines.append("")
+
+        if cold_start_count > 0:
+            cold_start_ads = [r for r in results if r.get("is_cold_start")]
+            lines.append(f"【冷启动保护 - {cold_start_count}个,标记observe不调价】")
+            for item in cold_start_ads[:5]:
+                lines.append(f"  {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']} | 消耗:{item['cost']:.0f}元")
+            if cold_start_count > 5:
+                lines.append(f"  ... 还有 {cold_start_count - 5} 个")
+            lines.append("")
+
+        # 汇总
+        action_counts = {}
+        for r in results:
+            action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
+        summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
+        lines.append(f"合计:{' / '.join(summary_parts)} / 样本不足:{len(df_nosample)} / 冷启动保护:{cold_start_count}")
+
+        # Step 9: 输出 Excel(按动作颜色标识)
+        try:
+            import openpyxl
+            from openpyxl.styles import PatternFill, Font
+
+            ACTION_COLORS = {
+                "increase": "C6EFCE",  # 绿
+                "decrease": "FFEB9C",  # 橙黄
+                "close":    "FFC7CE",  # 红
+                "observe":  "FFFF99",  # 黄
+                "keep":     "FFFFFF",  # 白
+            }
+
+            output_dir = Path(__file__).parent.parent / "outputs"
+            output_dir.mkdir(exist_ok=True)
+            xlsx_path = output_dir / f"adjustment_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+
+            headers_cn = ["日期", "账户ID", "广告ID", "广告名称", "动作", "当前出价(分)", "新出价(分)",
+                          "调整幅度", "ROI等级", "跑量等级", "效率分", "昨日消耗(元)", "打开数",
+                          "转化数", "冷启动", "冷启动原因", "广告状态"]
+            fields_en = ["date", "account_id", "ad_id", "ad_name", "action", "current_bid", "new_bid",
+                         "adjustment_ratio", "roi_level", "volume_level", "efficiency", "cost",
+                         "open_count", "conversions_count", "is_cold_start", "cold_start_reason", "ad_status"]
+
+            wb = openpyxl.Workbook()
+            ws = wb.active
+            ws.title = "调整方案"
+
+            # 表头(加粗)
+            ws.append(headers_cn)
+            for cell in ws[1]:
+                cell.font = Font(bold=True)
+
+            # 数据行 + 颜色
+            for row in results:
+                ws.append([row.get(f) for f in fields_en])
+                color = ACTION_COLORS.get(row.get("action", "keep"), "FFFFFF")
+                fill = PatternFill(fill_type="solid", fgColor=color)
+                for cell in ws[ws.max_row]:
+                    cell.fill = fill
+
+            # 冻结首行 + 列宽
+            ws.freeze_panes = "A2"
+            for col in ws.columns:
+                ws.column_dimensions[col[0].column_letter].width = 16
+
+            wb.save(xlsx_path)
+            lines.append(f"\n📄 完整方案已输出: {xlsx_path}")
+            logger.info("方案已保存: %s", xlsx_path)
+        except Exception as xlsx_err:
+            logger.warning("xlsx 输出失败(不影响主流程): %s", xlsx_err)
+
+        return ToolResult(
+            title=f"出价调整方案({len(results)}个广告,{direction}{abs(1-scale_ratio)*100:.0f}%)",
+            output="\n".join(lines),
+            metadata={
+                "adjustment_plan": results,
+                "strategy": strategy,
+                "scale_ratio": scale_ratio,
+                "thresholds": thresholds,
+                "yesterday_total": yesterday_total,
+                "total_budget_yuan": total_budget_yuan,
+                "nosample_count": len(df_nosample),
+                "cold_start_count": cold_start_count,
+                "action_counts": action_counts,
+            },
+        )
+
+    except Exception as e:
+        logger.error("budget_calculate_from_data 失败: %s", e, exc_info=True)
+        return ToolResult(title="budget_calculate_from_data 失败", output=str(e))
+
+
+# ===== 执行出价调整 =====
+
+@tool(description="执行出价调整方案")
+async def bid_adjustment_execute(
+    adjustment_plan: List[Dict],
+    account_id: int,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    批量执行出价调整
+
+    Args:
+        adjustment_plan: 调整方案列表,每项包含 ad_id, new_bid, action
+        account_id: 账户ID
+    """
+    success_count = 0
+    failed_count = 0
+    errors = []
+
+    for item in adjustment_plan:
+        if item["action"] not in ("increase", "decrease"):
+            continue
+        try:
+            await ad_update(
+                account_id=account_id,
+                adgroup_id=item["ad_id"],
+                bid_amount=item["new_bid"]
+            )
+            success_count += 1
+            logger.info("调整出价: ad_id=%s, new_bid=%s", item["ad_id"], item["new_bid"])
+        except Exception as e:
+            failed_count += 1
+            error_msg = f"ad_id={item['ad_id']}: {str(e)}"
+            errors.append(error_msg)
+            logger.error("执行失败: %s", error_msg)
+
+    output_lines = [
+        "执行完成:",
+        f"- 成功调整: {success_count} 个",
+        f"- 失败: {failed_count} 个",
+    ]
+
+    if errors:
+        output_lines.append("\n失败详情:")
+        for err in errors[:10]:
+            output_lines.append(f"  {err}")
+        if len(errors) > 10:
+            output_lines.append(f"  ... 还有 {len(errors)-10} 个错误")
+
+    return ToolResult(
+        title="出价调整执行结果",
+        output="\n".join(output_lines),
+    )

+ 271 - 0
examples/auto_put_ad/tools/strategy_config.py

@@ -0,0 +1,271 @@
+"""
+策略配置层 — 预算策略参数的持久化、加载、更新、版本管理
+
+所有策略参数(分位数阈值、决策矩阵、调价幅度、保护规则)
+从 JSON 配置文件加载,不在工具代码中硬编码。
+LLM + Skill 决定是否调整参数,工具按参数执行确定性计算。
+"""
+
+import copy
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+logger = logging.getLogger(__name__)
+
+# 配置文件路径
+CONFIGS_DIR = Path(__file__).parent.parent / "configs"
+ACTIVE_CONFIG = CONFIGS_DIR / "budget_strategy_v1.json"
+
+
+def _load_config_from_file(path: Path = ACTIVE_CONFIG) -> dict:
+    """从 JSON 文件加载配置"""
+    if not path.exists():
+        raise FileNotFoundError(f"配置文件不存在: {path}")
+    with open(path, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _save_config_to_file(config: dict, path: Path = ACTIVE_CONFIG):
+    """保存配置到 JSON 文件"""
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(config, f, ensure_ascii=False, indent=2)
+
+
+@tool(description="加载当前预算策略配置,包括分位数阈值、决策矩阵、调价幅度、保护规则等")
+async def load_strategy_config(
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    加载当前预算策略配置。
+
+    返回完整的策略配置,包括:
+    - thresholds: 分位数阈值(roi_high_percentile, roi_low_percentile, cost_mid_percentile)
+    - decision_matrix: 决策矩阵(5种策略 × 6种象限 → 动作+幅度)
+    - bid_limits: 出价边界和调价幅度上限
+    - protection: 保护规则(冷启动、赔付)
+    - version/last_updated/updated_by/update_reason: 版本元数据
+    """
+    try:
+        config = _load_config_from_file()
+
+        # 格式化输出
+        lines = [
+            f"策略配置版本: {config['version']}",
+            f"最后更新: {config['last_updated']}",
+            f"更新者: {config['updated_by']}",
+            f"更新原因: {config['update_reason']}",
+            "",
+            "=== 分位数阈值 ===",
+            f"  ROI 高阈值: P{int(config['thresholds']['roi_high_percentile']*100)}",
+            f"  ROI 低阈值: P{int(config['thresholds']['roi_low_percentile']*100)}",
+            f"  消耗中位数: P{int(config['thresholds']['cost_mid_percentile']*100)}",
+            "",
+            "=== 调价幅度限制 ===",
+            f"  最大提价: +{int(config['bid_limits']['max_increase_pct']*100)}%",
+            f"  最大降价: {int(config['bid_limits']['max_decrease_pct']*100)}%",
+            f"  出价范围: {config['bid_limits']['min_bid']}分 ~ {config['bid_limits']['max_bid']}分",
+            "",
+            "=== 保护规则 ===",
+            f"  冷启动保护: {'开启' if config['protection']['cold_start_enabled'] else '关闭'}",
+            f"    冷启动期: {config['protection']['cold_start_hours']}h, 最少转化: {config['protection']['cold_start_min_conversions']}",
+            f"  赔付保护: {'开启' if config['protection']['compensation_enabled'] else '关闭'}",
+            f"    赔付门槛: 转化>={config['protection']['compensation_min_conversions']}, CPA偏离>={int(config['protection']['compensation_cpa_deviation_pct']*100)}%",
+            "",
+            "=== 决策矩阵(5种策略) ===",
+        ]
+
+        for strategy_name, matrix in config["decision_matrix"].items():
+            lines.append(f"  [{strategy_name}]")
+            for quadrant, (action, ratio) in matrix.items():
+                ratio_str = f"{ratio:+.0%}" if ratio != 0 else "-"
+                lines.append(f"    {quadrant}: {action} ({ratio_str})")
+
+        output = "\n".join(lines)
+
+        return ToolResult(
+            title="策略配置加载成功",
+            output=output,
+            long_term_memory=f"已加载策略配置 {config['version']}({config['last_updated']},{config['update_reason']})",
+        )
+
+    except Exception as e:
+        logger.error(f"加载策略配置失败: {e}")
+        return ToolResult(title="策略配置加载失败", output=str(e))
+
+
+@tool(description="更新预算策略配置的指定字段,记录更新原因和版本历史")
+async def update_strategy_config(
+    updates: str,
+    reason: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    更新预算策略配置的指定字段。
+
+    Args:
+        updates: JSON 格式的更新内容,支持嵌套路径。示例:
+            - 调整分位数: {"thresholds": {"roi_high_percentile": 0.60}}
+            - 调整决策矩阵某项: {"decision_matrix": {"moderate_scale_down": {"mid_high": ["keep", 0.0]}}}
+            - 调整调价幅度: {"bid_limits": {"max_decrease_pct": -0.10}}
+            - 调整保护规则: {"protection": {"cold_start_hours": 72}}
+        reason: 更新原因,如 "周末流量质量高,ROI阈值放宽到P60"
+    """
+    try:
+        config = _load_config_from_file()
+
+        # 解析更新内容
+        try:
+            update_dict = json.loads(updates)
+        except json.JSONDecodeError as e:
+            return ToolResult(
+                title="更新失败",
+                output=f"updates 参数不是合法 JSON: {e}",
+            )
+
+        # 保存更新前的快照到 history
+        old_version = config["version"]
+        snapshot = {
+            "version": old_version,
+            "timestamp": config["last_updated"],
+            "updated_by": config["updated_by"],
+            "reason": config["update_reason"],
+            "changes": updates,
+        }
+        if "history" not in config:
+            config["history"] = []
+        config["history"].append(snapshot)
+        # 只保留最近 50 条历史
+        config["history"] = config["history"][-50:]
+
+        # 递归合并更新
+        def deep_merge(base: dict, update: dict):
+            for key, value in update.items():
+                if key in base and isinstance(base[key], dict) and isinstance(value, dict):
+                    deep_merge(base[key], value)
+                else:
+                    base[key] = value
+
+        deep_merge(config, update_dict)
+
+        # 更新版本元数据
+        version_num = int(old_version.split("v")[1].split(".")[0]) if "v" in old_version else 1
+        minor = int(old_version.split(".")[-1]) if "." in old_version else 0
+        config["version"] = f"v{version_num}.{minor + 1}"
+        config["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        config["updated_by"] = "agent"
+        config["update_reason"] = reason
+
+        # 保存
+        _save_config_to_file(config)
+
+        output = (
+            f"配置已更新: {old_version} → {config['version']}\n"
+            f"更新时间: {config['last_updated']}\n"
+            f"更新原因: {reason}\n"
+            f"更新内容: {updates}"
+        )
+
+        return ToolResult(
+            title=f"策略配置更新成功 → {config['version']}",
+            output=output,
+            long_term_memory=f"策略配置从 {old_version} 更新到 {config['version']},原因:{reason}",
+        )
+
+    except Exception as e:
+        logger.error(f"更新策略配置失败: {e}")
+        return ToolResult(title="策略配置更新失败", output=str(e))
+
+
+@tool(description="查看预算策略配置的变更历史")
+async def get_config_history(
+    limit: int = 10,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    查看预算策略配置的变更历史。
+
+    Args:
+        limit: 返回的历史记录条数,默认 10
+    """
+    try:
+        config = _load_config_from_file()
+        history = config.get("history", [])
+
+        if not history:
+            return ToolResult(
+                title="配置变更历史",
+                output="暂无变更历史,当前为初始版本。",
+            )
+
+        # 取最近 N 条,倒序展示
+        recent = history[-limit:][::-1]
+        lines = [f"配置变更历史(最近 {len(recent)} 条)", ""]
+
+        for i, entry in enumerate(recent, 1):
+            lines.append(f"[{i}] {entry['version']} ({entry['timestamp']})")
+            lines.append(f"    更新者: {entry['updated_by']}")
+            lines.append(f"    原因: {entry['reason']}")
+            lines.append(f"    变更: {entry['changes']}")
+            lines.append("")
+
+        return ToolResult(
+            title=f"配置变更历史({len(recent)}条)",
+            output="\n".join(lines),
+        )
+
+    except Exception as e:
+        logger.error(f"获取配置历史失败: {e}")
+        return ToolResult(title="获取配置历史失败", output=str(e))
+
+
+# ===== 辅助函数(供其他工具调用) =====
+
+def get_strategy_config() -> dict:
+    """同步加载策略配置(供内部工具调用)"""
+    return _load_config_from_file()
+
+
+def get_decision_matrix(config: dict, strategy: str) -> dict:
+    """从配置中获取指定策略的决策矩阵
+
+    Args:
+        config: 完整配置字典
+        strategy: 策略名称,如 "aggressive_scale_down"
+
+    Returns:
+        dict: 象限 → (action, ratio) 的映射
+    """
+    matrix_raw = config["decision_matrix"].get(strategy, config["decision_matrix"]["maintain"])
+    return {k: tuple(v) for k, v in matrix_raw.items()}
+
+
+def determine_strategy(scale_ratio: float, config: dict) -> str:
+    """根据缩量/扩量比例和配置确定策略
+
+    Args:
+        scale_ratio: 预算/昨日消耗 比值
+        config: 完整配置字典
+
+    Returns:
+        策略名称字符串
+    """
+    boundaries = config.get("strategy_boundaries", {
+        "aggressive_scale_down": [0, 0.70],
+        "moderate_scale_down": [0.70, 0.95],
+        "maintain": [0.95, 1.05],
+        "moderate_scale_up": [1.05, 1.30],
+        "aggressive_scale_up": [1.30, 999],
+    })
+
+    for strategy_name, (low, high) in boundaries.items():
+        if low <= scale_ratio < high:
+            return strategy_name
+
+    return "maintain"