Selaa lähdekoodia

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 kuukausi sitten
vanhempi
commit
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
 from datetime import datetime, timedelta
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 
+import pandas as pd
+
 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
+from examples.auto_put_ad.tools.strategy_config import (
+    get_strategy_config,
+    get_decision_matrix,
+    determine_strategy,
+)
 
 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)"""
@@ -124,12 +44,7 @@ def _parse_bizdate(bizdate: str) -> tuple:
 
 
 def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
-    """构建昨日效率数据 SQL(广告维度聚合)
-
-    包含冷启动保护所需字段:
-    - create_time: 广告创建时间(判定冷启动期 48h)
-    - conversions_count: 转化量(判定赔付门槛 6 次)
-    """
+    """构建昨日效率数据 SQL(广告维度聚合)"""
     return f"""
 SELECT
     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",
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
     """
-    账户级评估:查询各账户昨日汇总数据,按消耗量判断稳定性
+    拉取昨日广告效果数据(广告维度聚合)
 
     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:
         client = _get_odps_client()
         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)
 
-        # 查询各账户昨日汇总(复用效率 SQL,按 account_id 聚合)
         inner_sql = _build_efficiency_sql(biz, biz_dash)
         sql = f"""
 SELECT
@@ -211,15 +227,13 @@ GROUP BY account_id
 """
         df = client.execute_sql(sql)
         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(
             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)
 
@@ -234,10 +248,9 @@ GROUP BY account_id
         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"账户汇总({biz},共 {len(df)} 个账户)",
+            f"消耗中位数: {median_cost:,.0f}元",
             "",
             f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
             "-" * 55,
@@ -249,7 +262,6 @@ GROUP BY account_id
                 f"{row['stability']:>6}"
             )
 
-        # 标记建议扩量的账户
         stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
         if not stable_high_eff.empty:
             lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
@@ -257,7 +269,7 @@ GROUP BY account_id
                 lines.append(f"  账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
 
         return ToolResult(
-            title=f"账户评估({len(df)}个账户)",
+            title=f"账户汇总({len(df)}个账户)",
             output="\n".join(lines),
             metadata={
                 "accounts": df.to_dict("records"),
@@ -267,184 +279,304 @@ GROUP BY account_id
         )
 
     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,
 ) -> 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)
+        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:
-        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:
-            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 = []
         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
             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:
                 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:
-                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
-            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({
                 "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,
                 "is_cold_start": is_cold_start,
                 "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,
-                "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "",
+                "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 "",
+                "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 = [
-            f"出价调整方案({direction} {abs(1-scale_ratio)*100:.0f}%)",
-            f"昨日消耗: {yesterday_total:,.0f} 元 → 今日预算: {total_budget_yuan:,.0f} 元",
+            f"出价调整方案({len(results)}条广告)",
             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:
@@ -460,109 +592,52 @@ WHERE ad_id IN ({ad_ids_str})
                 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}元")
+            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:
                 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)
+        lines.append(f"合计: {' / '.join(summary_parts)} / 冷启动保护:{cold_start_count}")
 
         return ToolResult(
-            title=f"出价调整方案({len(results)}个广告,{direction}{abs(1-scale_ratio)*100:.0f}%)",
+            title=f"出价调整方案({len(results)}条,{strategy})",
             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,
+                "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:
-        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(
     adjustment_plan: List[Dict],
     account_id: int,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
     """
-    批量执行出价调整
+    批量执行出价调整。
 
     Args:
         adjustment_plan: 调整方案列表,每项包含 ad_id, new_bid, action
@@ -606,3 +681,12 @@ async def bid_adjustment_execute(
         title="出价调整执行结果",
         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"