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

feat(auto_put_ad_mini): 投手经验对齐修复+决策引擎数据补全

核心修复:
- 提价消耗条件:cost_median(=0)→固定阈值1000元(投手经验原文)
- 成熟期(>7天)禁止提价,仅允许scale_up扩量
- 关停增加昨日消耗≥300+年龄>3天门槛(投手经验2.4)
- 降价后≥7天ROI仍低→自动升级为关停候选
- LLM输入数据补全:ad_fission/ad_ctr/yesterday_cost
- guardrails历史保留7→14天,新增get_last_bid_down_ts()
- roi_calculator输出新增yesterday_cost列
- roi_strategy.md同步更新领域知识

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
刘立冬 3 недель назад
Родитель
Сommit
0e4f248ebc

+ 15 - 16
examples/auto_put_ad_mini/config.py

@@ -2,7 +2,7 @@
 广告智能决策引擎配置 — auto_put_ad_mini
 
 运营可直接修改此文件调整决策参数。
-当前模式:规则判断 + 智能判断
+当前模式:智能判断
   - 基于 f_7日动态ROI 的精细化决策
   - AI 推理结合领域知识
   - 三级分类:零消耗待关停(规则)+ 待优化评估(智能)+ 正常运行(规则)
@@ -22,10 +22,6 @@ except ImportError:
 # Agent 运行配置
 # ═══════════════════════════════════════════
 
-# 引擎开关
-USE_RULE_ENGINE = False  # True=使用规则引擎, False=只用智能引擎
-USE_AI_ENGINE = True     # True=使用智能引擎, False=只用规则引擎
-
 MAIN_CONFIG = RunConfig(
     model="qwen/qwen3.5-plus-02-15",
     temperature=0.3,
@@ -48,9 +44,6 @@ MAIN_CONFIG = RunConfig(
         "check_approval_status",
         # 飞书文档(报告导入 & 分享):
         "import_to_feishu",
-        # 规则引擎工具(暂时禁用):
-        # "analyze_ads",
-        # "compare_decisions",
     ],
     skills=["roi-strategy", "guardrail-rules"],
     # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking
@@ -78,7 +71,7 @@ ROI_CALCULATION_DAYS = 7  # f_7日动态ROI 计算窗口
 # ═══════════════════════════════════════════
 MIN_DAILY_COST = 100  # 日消耗 >= 100元才参与 ROI 计算
 MIN_AD_AGE_DAYS = 3  # 广告创建 >= 3天才参与决策(与 min_periods 对齐)
-ROI_LOW_FACTOR = 0.5  # f_7日动态ROI < 全体均值 × 0.5 → 关停
+ROI_LOW_FACTOR = 0.75  # f_7日动态ROI < 全体均值 × 0.75 → 关停
 NO_SPEND_THRESHOLD = 10  # 7日消耗均值 < 10元 → 关停
 STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 
@@ -86,13 +79,18 @@ STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 # 出价调整配置
 # ═══════════════════════════════════════════
 BID_ADJUSTMENT_ENABLED = True
-BID_DOWN_ROI_FACTOR = 0.8       # ROI < 均值×0.8 → 考虑降价
-BID_UP_ROI_FACTOR = 1.2         # ROI > 均值×1.2 → 考虑提价
-BID_UP_LOW_SPEND_FACTOR = 0.5   # 消耗 < 中位数×0.5 → 消耗不足
-BID_CHANGE_MIN_PCT = 0.03       # 最小调幅 3%
-BID_CHANGE_MAX_PCT = 0.10       # 最大单次调幅 10%
-BID_FLOOR_YUAN = 0.50           # 出价下限(元)
-BID_CEILING_YUAN = 200.00       # 出价上限(元)
+BID_DOWN_ROI_FACTOR = 0.90      # ROI < 均值×0.90 → 考虑降价(低于渠道均值10%)
+BID_UP_ROI_FACTOR = 1.05        # ROI > 均值×1.05 → 考虑提价(高于渠道均值5%)
+BID_UP_MAX_SPEND = 1000          # 提价消耗上限:均值消耗<1000才提价(投手经验原文)
+BID_CHANGE_MIN_PCT = 0.03       # 最小调幅 3%(兼容旧代码)
+BID_CHANGE_MAX_PCT = 0.10       # 最大单次调幅 10%(兼容旧代码)
+BID_UP_MIN_PCT = 0.05           # 提价最小幅度 5%
+BID_UP_MAX_PCT = 0.10           # 提价最大幅度 10%
+BID_DOWN_MIN_PCT = 0.03         # 降价最小幅度 3%
+BID_DOWN_MAX_PCT = 0.05         # 降价最大幅度 5%
+BID_DOWN_MIN_SPEND = 500        # 降价消耗门槛:7日日均消耗≥500元
+BID_FLOOR_YUAN = 0.05           # 出价下限(元)
+BID_CEILING_YUAN = 1.00         # 出价上限(元)
 
 # 广告年龄分段(基于决策树图片)
 COLD_START_DAYS = 3             # 冷启动期(≤3天):极度保护,几乎不干预
@@ -106,6 +104,7 @@ CAUTIOUS_DAYS = EARLY_GROWTH_DAYS  # 兼容旧代码
 # 高燃烧预警配置
 HIGH_BURN_AGE_THRESHOLD = 3     # 广告年龄>3天才检查
 HIGH_BURN_COST_THRESHOLD = 300  # 昨日消耗>300元触发预警
+ROI_LOW_MIN_YESTERDAY_COST = 300  # 关停消耗门槛:昨日消耗≥300才检查关停(投手经验2.4)
 
 # ═══════════════════════════════════════════
 # 安全护栏配置

+ 2 - 2
examples/auto_put_ad_mini/execute_once.py

@@ -28,8 +28,8 @@ from examples.auto_put_ad_mini.config import (
 # 导入自定义工具
 from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
 from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
-from examples.auto_put_ad_mini.tools.ad_decision import analyze_ads, get_ads_for_review, apply_decisions, query_ad_detail, modify_decisions
-from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
+from examples.auto_put_ad_mini.tools.ad_decision import get_ads_for_review, apply_decisions, query_ad_detail, modify_decisions
+from examples.auto_put_ad_mini.tools.report_generator import generate_report
 from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
 from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
 from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status

+ 0 - 1
examples/auto_put_ad_mini/presets.json

@@ -6,7 +6,6 @@
     "allowed_tools": [
       "fetch_creative_data",
       "calculate_roi_metrics",
-      "analyze_ads",
       "generate_report"
     ],
     "skills": ["roi-strategy"]

+ 2 - 2
examples/auto_put_ad_mini/run.py

@@ -35,10 +35,10 @@ from examples.auto_put_ad_mini.config import (
 from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
 from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
 from examples.auto_put_ad_mini.tools.ad_decision import (
-    analyze_ads, get_ads_for_review, apply_decisions,
+    get_ads_for_review, apply_decisions,
     query_ad_detail, modify_decisions,
 )
-from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
+from examples.auto_put_ad_mini.tools.report_generator import generate_report
 from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
 from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
 from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status

+ 16 - 5
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -147,6 +147,7 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 - 如果 ROI 低且消耗高 → **积极降价**(优化成本)
 - 如果近期换过创意 → **观察 2-3 天再降价**
 - 如果近期已降价 → **避免频繁调整**
+- 降价时同时建议运营调整素材方向(不自动执行,仅在理由中输出)
 
 **降价幅度**:
 - 低于同类 10-12% → 降价 3-5%
@@ -159,17 +160,26 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 **什么时候考虑关停**:
 当广告表现远低于同类,明显低效时
 
+**前提条件**(硬规则已在代码中实现):
+- 昨日消耗 ≥ 300 元(消耗太低的广告数据不可靠)
+- 广告年龄 > 3 天(冷启动期不评估关停)
+- 不满足以上条件的低 ROI 广告会被归入 hold
+
 **参考标准**:
 - ROI 低于同类中位数 **25-30%** 或更多
 - 已处于同类最差水平
 
+**持续低 ROI 升级关停**:
+- 如果广告之前被降价过,且降价后 ≥ 7 天 ROI 仍低于渠道均值 → 自动升级为关停候选
+- 这类广告会带有 `persistent_low_roi=True` 标记
+
 **重要**:
 以下情况已被硬规则直接关停,不会到达你这里:
 - 7 天无消耗(< 10 元)
 - 预算耗尽
 - 明显衰退(提价后仍低)
 
-你需要判断的是:**ROI 低但有一定消耗的广告**
+你需要判断的是:**ROI 低但有一定消耗(昨日≥300元)的广告**
 
 ---
 
@@ -226,18 +236,19 @@ LLM建议: bid_down -5%
 
 ---
 
-### 第3段:成熟期(>7天)— 可全面调控
+### 第3段:成熟期(>7天)— 可降价/关停/扩量,不提价
 
 **特征**:
 - 数据充分(≥7天),ROI稳定
 - 系统学习完成
-- 可全面调控
+- 稳定期不调出价,通过新增广告/创意拿消耗(投手经验1.2)
 
 **策略**:
 - ✅ **可降价**,最大5%(`max_bid_down_pct=0.05`)— 决策树明确3-5%上限
-- ✅ **可提价**,5-10%
+- ❌ **不提价**(稳定期不调出价,通过新增广告/创意扩量)
+- ✅ **可扩量**(scale_up,建议新增广告或创意拿消耗)
 - ✅ **可关停**(ROI低于同类中位数25-30%)
-- ✅ 所有动作均可使用
+- ✅ 可降价、观察、调整素材方向
 
 **数据标识**:
 - `age_segment`: `mature`

+ 5 - 6
examples/auto_put_ad_mini/strategy_params.json

@@ -1,12 +1,11 @@
 {
   "version": "1.0",
-  "last_updated": "2026-04-17",
-  "updated_by": "system_init",
+  "last_updated": "2026-04-20",
+  "updated_by": "decision_tree_alignment",
   "params": {
-    "ROI_LOW_FACTOR": 0.5,
-    "BID_DOWN_ROI_FACTOR": 0.8,
-    "BID_UP_ROI_FACTOR": 1.2,
-    "BID_UP_LOW_SPEND_FACTOR": 0.5
+    "ROI_LOW_FACTOR": 0.75,
+    "BID_DOWN_ROI_FACTOR": 0.90,
+    "BID_UP_ROI_FACTOR": 1.05
   },
   "adjustments": []
 }

+ 2 - 2
examples/auto_put_ad_mini/test_analysis_0415.py

@@ -29,10 +29,10 @@ from examples.auto_put_ad_mini.config import (
 from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
 from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
 from examples.auto_put_ad_mini.tools.ad_decision import (
-    analyze_ads, get_ads_for_review, apply_decisions,
+    get_ads_for_review, apply_decisions,
     query_ad_detail, modify_decisions,
 )
-from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
+from examples.auto_put_ad_mini.tools.report_generator import generate_report
 from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
 from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
 from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status

+ 143 - 600
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -1,22 +1,14 @@
 """
-广告决策引擎 — auto_put_ad_mini V3
-
-V3 三维度决策引擎:
-  维度 1(最高优先级): ROI 过低 → 关停
-  维度 2: 长期无消耗 → 关停
-  维度 3: 广告衰退 → 关停
-
-设计:
-  - DecisionDimension 基类 + 优先级注册
-  - 第一个命中的维度决定动作,后续不再评估
-  - 不满 7 天的广告不参与决策
-  - 所有阈值通过参数传入(来自 SKILL)
+广告决策引擎 — auto_put_ad_mini
+
+智能引擎:
+  - LLM 推理 + 候选信号(ROI/裂变/CTR/消耗)驱动
+  - 三级分类:零消耗待关停(规则)+ 待优化评估(LLM)+ 正常运行(规则)
+  - 年龄保护三层架构(冷启动 / 早期成长 / 成熟期)
 """
 
 import logging
 import sys
-from abc import ABC, abstractmethod
-from dataclasses import dataclass
 from datetime import datetime, timedelta
 from pathlib import Path
 from typing import Dict, List, Optional, Tuple
@@ -36,9 +28,14 @@ from config import (
     BID_ADJUSTMENT_ENABLED,
     BID_DOWN_ROI_FACTOR,
     BID_UP_ROI_FACTOR,
-    BID_UP_LOW_SPEND_FACTOR,
+    BID_UP_MAX_SPEND,
     BID_CHANGE_MIN_PCT,
     BID_CHANGE_MAX_PCT,
+    BID_UP_MIN_PCT,
+    BID_UP_MAX_PCT,
+    BID_DOWN_MIN_PCT,
+    BID_DOWN_MAX_PCT,
+    BID_DOWN_MIN_SPEND,
     BID_FLOOR_YUAN,
     BID_CEILING_YUAN,
     COLD_START_DAYS,       # ≤3天:冷启动期(极度保护)
@@ -47,6 +44,7 @@ from config import (
     HIGH_BURN_AGE_THRESHOLD,
     HIGH_BURN_COST_THRESHOLD,
     ROI_LOW_FACTOR,
+    ROI_LOW_MIN_YESTERDAY_COST,
 )
 
 logger = logging.getLogger(__name__)
@@ -74,7 +72,6 @@ def _load_strategy_params():
         "ROI_LOW_FACTOR": ROI_LOW_FACTOR,
         "BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
         "BID_UP_ROI_FACTOR": BID_UP_ROI_FACTOR,
-        "BID_UP_LOW_SPEND_FACTOR": BID_UP_LOW_SPEND_FACTOR,
     }
 
 
@@ -122,436 +119,6 @@ def _calculate_ad_age_days(create_time) -> Optional[int]:
         return None
 
 
-# ═══════════════════════════════════════════
-# 决策结果数据类
-# ═══════════════════════════════════════════
-
-
-@dataclass
-class Decision:
-    """单个广告的决策结果。"""
-    ad_id: int
-    action: str  # "pause" / "bid_down" / "bid_up" / "hold" / "creative_adjust" / "observe"
-    dimension: str  # "ROI过低" / "长期无消耗" / "广告衰退" / "ROI偏低-降价" / "高ROI低量-提价" / "保持"
-    reason: str  # 详细原因
-    recommended_change_pct: Optional[float] = None  # +0.05 = 提价5%, -0.08 = 降价8%
-    current_bid: Optional[float] = None             # 当前出价(元)
-    recommended_bid: Optional[float] = None          # 建议出价(元)
-
-
-# ═══════════════════════════════════════════
-# 决策维度基类(可扩展)
-# ═══════════════════════════════════════════
-
-
-class DecisionDimension(ABC):
-    """决策维度基类。"""
-
-    def __init__(self, priority: int):
-        self.priority = priority
-
-    @abstractmethod
-    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
-        """
-        评估单个广告是否命中该维度。
-
-        Args:
-            row: 广告数据行(包含所有指标)
-            context: 全局上下文(如全体均值、阈值参数)
-
-        Returns:
-            Decision 对象(命中)或 None(不命中)
-        """
-        pass
-
-
-# ═══════════════════════════════════════════
-# 维度 1: ROI 过低
-# ═══════════════════════════════════════════
-
-
-class ROITooLowDimension(DecisionDimension):
-    """维度 1: 动态ROI_7日均值 < 全体均值 × 0.5 → 关停。"""
-
-    def __init__(self):
-        super().__init__(priority=1)
-
-    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
-        ad_age_days = row.get("ad_age_days")
-        cost_7d_avg = row.get("cost_7d_avg", 0)
-        f_roi_7d = row.get("动态ROI_7日均值")   # 决策参考值
-        f_roi_mean_all = context.get("动态ROI_mean_all")
-
-        min_ad_age = context.get("min_ad_age_days", 7)
-        min_daily_cost = context.get("min_daily_cost", 100)
-        roi_low_factor = context.get("roi_low_factor", 0.5)
-
-        # 前置条件
-        if ad_age_days is None or ad_age_days < min_ad_age:
-            return None
-        if cost_7d_avg < min_daily_cost:
-            return None
-        if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all):
-            return None
-
-        # 判断
-        threshold = f_roi_mean_all * roi_low_factor
-        if f_roi_7d < threshold:
-            return Decision(
-                ad_id=int(row["ad_id"]),
-                action="pause",
-                dimension="ROI过低",
-                reason=f"动态ROI_7日均值={f_roi_7d:.4f} < 全体均值×{roi_low_factor}={threshold:.4f}"
-            )
-
-        return None
-
-
-# ═══════════════════════════════════════════
-# 维度 2: 长期无消耗
-# ═══════════════════════════════════════════
-
-
-class NoSpendDimension(DecisionDimension):
-    """维度 2: 7日消耗均值 < 10元 → 关停。"""
-
-    def __init__(self):
-        super().__init__(priority=2)
-
-    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
-        ad_age_days = row.get("ad_age_days")
-        cost_7d_avg = row.get("cost_7d_avg", 0)
-
-        min_ad_age = context.get("min_ad_age_days", 7)
-        no_spend_threshold = context.get("no_spend_threshold", 10)
-
-        # 前置条件
-        if ad_age_days is None or ad_age_days < min_ad_age:
-            return None
-
-        # 判断
-        if cost_7d_avg < no_spend_threshold:
-            return Decision(
-                ad_id=int(row["ad_id"]),
-                action="pause",
-                dimension="长期无消耗",
-                reason=f"7日消耗均值={cost_7d_avg:.2f}元 < {no_spend_threshold}元"
-            )
-
-        return None
-
-
-# ═══════════════════════════════════════════
-# 维度 3: 广告衰退
-# ═══════════════════════════════════════════
-
-
-class AdDecayDimension(DecisionDimension):
-    """
-    维度 3: 广告衰退 → 关停。
-
-    条件:
-      - 30 天内曾连续稳定消耗(>100元/天)
-      - 近 7 天已提价或换创意
-      - 但消耗仍低(<100元)
-    """
-
-    def __init__(self):
-        super().__init__(priority=3)
-
-    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
-        stable_spend_days_30d = row.get("stable_spend_days_30d", 0)
-        cost_7d_avg = row.get("cost_7d_avg", 0)
-        bid_increased_7d = row.get("bid_increased_7d", False)
-        creative_changed_7d = row.get("creative_changed_7d", False)
-
-        stable_threshold = context.get("stable_spend_threshold", 100)
-
-        # 判断
-        if stable_spend_days_30d >= 7:  # 曾稳定消耗
-            if cost_7d_avg < stable_threshold:  # 现在消耗低
-                if bid_increased_7d or creative_changed_7d:  # 已干预
-                    reason_parts = []
-                    if bid_increased_7d:
-                        reason_parts.append("已提价")
-                    if creative_changed_7d:
-                        reason_parts.append("已换创意")
-                    reason = f"30天内曾稳定消耗{stable_spend_days_30d}天,近7天{'+'.join(reason_parts)},但消耗仍低({cost_7d_avg:.2f}元)"
-
-                    return Decision(
-                        ad_id=int(row["ad_id"]),
-                        action="pause",
-                        dimension="广告衰退",
-                        reason=reason
-                    )
-
-        return None
-
-
-# ═══════════════════════════════════════════
-# 维度 4: 出价偏高 — 降价
-# ═══════════════════════════════════════════
-
-
-class BidDownDimension(DecisionDimension):
-    """
-    维度 4: ROI 偏低但未达关停线 → 降价。
-
-    触发条件:
-      - ROI 在 均值×0.5 ~ 均值×0.8 之间(偏低但非极低)
-      - 日消耗 ≥ 100 元(数据有统计意义)
-      - 非冷启动期(> {COLD_START_DAYS} 天,即≥4天)
-      - 有出价数据(bid_amount > 0)
-
-    降幅计算:
-      ROI 距离关停线越近 → 降幅越大(最大 -10%)
-      ROI 刚好低于正常线 → 小幅降价(-3%~-5%)
-      公式:change_pct = -3% - 7% × (1 - (ROI - hard_stop) / (normal_line - hard_stop))
-    """
-
-    def __init__(self):
-        super().__init__(priority=4)
-
-    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
-        if not context.get("bid_adjustment_enabled", BID_ADJUSTMENT_ENABLED):
-            return None
-
-        ad_age_days = row.get("ad_age_days")
-        cost_7d_avg = row.get("cost_7d_avg", 0)
-        f_roi_7d = row.get("动态ROI_7日均值")
-        f_roi_mean_all = context.get("动态ROI_mean_all")
-        bid_amount = row.get("bid_amount", 0)  # 元
-
-        cold_start = context.get("cold_start_days", COLD_START_DAYS)
-        cautious = context.get("cautious_days", CAUTIOUS_DAYS)
-        min_daily_cost = context.get("min_daily_cost", 100)
-        roi_low_factor = context.get("roi_low_factor", 0.5)
-        bid_down_factor = context.get("bid_down_roi_factor", BID_DOWN_ROI_FACTOR)
-
-        # 前置条件
-        if ad_age_days is None or ad_age_days < cold_start:
-            return None
-        if cost_7d_avg < min_daily_cost:
-            return None
-        if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all) or f_roi_mean_all <= 0:
-            return None
-        if not bid_amount or bid_amount <= 0:
-            return None
-
-        hard_stop_line = f_roi_mean_all * roi_low_factor
-        normal_line = f_roi_mean_all * bid_down_factor
-
-        # ROI 必须在 hard_stop ~ normal_line 之间
-        if f_roi_7d >= normal_line or f_roi_7d < hard_stop_line:
-            return None
-
-        # 计算降幅
-        range_width = normal_line - hard_stop_line
-        if range_width <= 0:
-            return None
-        ratio = 1 - (f_roi_7d - hard_stop_line) / range_width
-        change_pct = -(BID_CHANGE_MIN_PCT + (BID_CHANGE_MAX_PCT - BID_CHANGE_MIN_PCT) * ratio)
-
-        # 谨慎期(4-7天)限制最大降幅 5%
-        if ad_age_days < cautious:
-            change_pct = max(change_pct, -0.05)
-
-        # 计算建议出价
-        new_bid = bid_amount * (1 + change_pct)
-        new_bid = max(new_bid, BID_FLOOR_YUAN)
-        new_bid = min(new_bid, BID_CEILING_YUAN)
-        new_bid = round(new_bid, 2)
-
-        # 实际调幅重算(边界钳位后)
-        actual_pct = (new_bid - bid_amount) / bid_amount if bid_amount > 0 else 0
-
-        return Decision(
-            ad_id=int(row["ad_id"]),
-            action="bid_down",
-            dimension="ROI偏低-降价",
-            reason=(
-                f"动态ROI_7日均值={f_roi_7d:.4f},"
-                f"在关停线{hard_stop_line:.4f}~正常线{normal_line:.4f}之间,"
-                f"当前出价{bid_amount:.2f}元,建议降{abs(actual_pct)*100:.1f}%至{new_bid:.2f}元"
-            ),
-            recommended_change_pct=round(actual_pct, 4),
-            current_bid=round(bid_amount, 2),
-            recommended_bid=new_bid,
-        )
-
-
-# ═══════════════════════════════════════════
-# 维度 5: 高ROI低量 — 提价
-# ═══════════════════════════════════════════
-
-
-class BidUpDimension(DecisionDimension):
-    """
-    维度 5: ROI 远超均值但消耗不足 → 提价放量。
-
-    触发条件:
-      - ROI > 均值×1.2
-      - 7日均消耗 < 全体中位数×0.5(消耗不足)
-      - 非冷启动期(> COLD_START_DAYS 天)
-      - 有出价数据(bid_amount > 0)
-
-    提幅计算:
-      提幅 = min(10%, (ROI/均值 - 1.2) × 20%)
-      最小提幅 3%
-    """
-
-    def __init__(self):
-        super().__init__(priority=5)
-
-    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
-        if not context.get("bid_adjustment_enabled", BID_ADJUSTMENT_ENABLED):
-            return None
-
-        ad_age_days = row.get("ad_age_days")
-        cost_7d_avg = row.get("cost_7d_avg", 0)
-        f_roi_7d = row.get("动态ROI_7日均值")
-        f_roi_mean_all = context.get("动态ROI_mean_all")
-        cost_median = context.get("cost_7d_avg_median", 0)
-        bid_amount = row.get("bid_amount", 0)  # 元
-
-        cold_start = context.get("cold_start_days", COLD_START_DAYS)
-        bid_up_factor = context.get("bid_up_roi_factor", BID_UP_ROI_FACTOR)
-        low_spend_factor = context.get("bid_up_low_spend_factor", BID_UP_LOW_SPEND_FACTOR)
-
-        # 前置条件
-        if ad_age_days is None or ad_age_days < cold_start:
-            return None
-        if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all) or f_roi_mean_all <= 0:
-            return None
-        if not bid_amount or bid_amount <= 0:
-            return None
-
-        # ROI 必须远超均值
-        roi_threshold = f_roi_mean_all * bid_up_factor
-        if f_roi_7d <= roi_threshold:
-            return None
-
-        # 消耗必须不足
-        spend_threshold = cost_median * low_spend_factor
-        if cost_7d_avg >= spend_threshold and spend_threshold > 0:
-            return None
-
-        # 计算提幅
-        roi_excess = f_roi_7d / f_roi_mean_all - bid_up_factor
-        change_pct = min(BID_CHANGE_MAX_PCT, roi_excess * 0.20)
-        change_pct = max(change_pct, BID_CHANGE_MIN_PCT)
-
-        # 计算建议出价
-        new_bid = bid_amount * (1 + change_pct)
-        new_bid = max(new_bid, BID_FLOOR_YUAN)
-        new_bid = min(new_bid, BID_CEILING_YUAN)
-        new_bid = round(new_bid, 2)
-
-        # 实际调幅重算
-        actual_pct = (new_bid - bid_amount) / bid_amount if bid_amount > 0 else 0
-
-        return Decision(
-            ad_id=int(row["ad_id"]),
-            action="bid_up",
-            dimension="高ROI低量-提价",
-            reason=(
-                f"动态ROI_7日均值={f_roi_7d:.4f} > 均值{f_roi_mean_all:.4f}×{bid_up_factor}={roi_threshold:.4f},"
-                f"但7日均消耗仅{cost_7d_avg:.2f}元 < 中位数{cost_median:.2f}×{low_spend_factor}={spend_threshold:.2f},"
-                f"当前出价{bid_amount:.2f}元,建议提{actual_pct*100:.1f}%至{new_bid:.2f}元"
-            ),
-            recommended_change_pct=round(actual_pct, 4),
-            current_bid=round(bid_amount, 2),
-            recommended_bid=new_bid,
-        )
-
-
-# ═══════════════════════════════════════════
-# 决策引擎
-# ═══════════════════════════════════════════
-
-
-def _run_decision_engine(
-    df: pd.DataFrame,
-    context: Dict
-) -> pd.DataFrame:
-    """
-    运行三维度决策引擎。
-
-    流程:
-      1. 注册所有维度(按优先级排序)
-      2. 对每个广告,按优先级评估维度
-      3. 第一个命中的维度决定动作
-      4. 不满 7 天的广告标记为"投放不足7日"
-
-    输入:
-      df: 广告级指标表(包含 动态ROI, cost_7d_avg, ad_age_days 等)
-      context: 全局上下文(阈值参数、全体均值)
-
-    输出:
-      添加 action, dimension, reason 列的 DataFrame
-    """
-    # 注册维度(含出价调整维度)
-    dimensions = [
-        ROITooLowDimension(),
-        NoSpendDimension(),
-        AdDecayDimension(),
-        BidDownDimension(),
-        BidUpDimension(),
-    ]
-    dimensions.sort(key=lambda d: d.priority)
-
-    decisions = []
-
-    for _, row in df.iterrows():
-        ad_age_days = row.get("ad_age_days")
-        min_ad_age = context.get("min_ad_age_days", 7)
-
-        # 不满 7 天的广告
-        if ad_age_days is None or ad_age_days < min_ad_age:
-            decisions.append(Decision(
-                ad_id=int(row["ad_id"]),
-                action="hold",
-                dimension="保持",
-                reason=f"投放不足{min_ad_age}日(当前{ad_age_days}日)"
-            ))
-            continue
-
-        # 按优先级评估维度
-        decision = None
-        for dim in dimensions:
-            decision = dim.evaluate(row, context)
-            if decision is not None:
-                break
-
-        # 无维度命中 → 保持
-        if decision is None:
-            decision = Decision(
-                ad_id=int(row["ad_id"]),
-                action="hold",
-                dimension="保持",
-                reason="各项指标正常"
-            )
-
-        decisions.append(decision)
-
-    # 转换为 DataFrame
-    decision_df = pd.DataFrame([
-        {
-            "ad_id": d.ad_id,
-            "action": d.action,
-            "dimension": d.dimension,
-            "reason": d.reason,
-            "recommended_change_pct": d.recommended_change_pct,
-            "current_bid": d.current_bid,
-            "recommended_bid": d.recommended_bid,
-        }
-        for d in decisions
-    ])
-
-    # 合并回原 DataFrame
-    df = df.merge(decision_df, on="ad_id", how="left")
-
-    return df
 
 
 # ═══════════════════════════════════════════
@@ -700,139 +267,6 @@ def _detect_decay_signals(
     return result
 
 
-# ═══════════════════════════════════════════
-# V3 工具:三维度决策
-# ═══════════════════════════════════════════
-
-
-@tool(description="V3 三维度决策引擎:ROI过低 / 长期无消耗 / 广告衰退")
-async def analyze_ads(
-    ctx: ToolContext,
-    metrics_csv: str,
-    end_date: str = "yesterday",
-    min_ad_age_days: int = 7,
-    min_daily_cost: float = 100.0,
-    roi_low_factor: float = 0.5,
-    no_spend_threshold: float = 10.0,
-    stable_spend_threshold: float = 100.0,
-) -> ToolResult:
-    """
-    V3 三维度决策引擎。
-
-    Args:
-        ctx: 工具上下文
-        metrics_csv: ROI 指标 CSV 路径(calculate_roi_metrics 输出)
-        end_date: 结束日期(YYYYMMDD 或 "yesterday")
-        min_ad_age_days: 最小广告年龄(天)
-        min_daily_cost: 最小日消耗(元)
-        roi_low_factor: ROI 过低因子(< 全体均值 × factor)
-        no_spend_threshold: 长期无消耗阈值(元)
-        stable_spend_threshold: 稳定消耗阈值(元/天)
-
-    Returns:
-        ToolResult,包含决策结果 DataFrame
-    """
-    try:
-        # 加载指标数据
-        df = pd.read_csv(metrics_csv)
-
-        if df.empty:
-            return ToolResult(
-                title="决策引擎",
-                output="指标数据为空,无法决策",
-            )
-
-        # 解析日期
-        if end_date == "yesterday":
-            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
-
-        # 计算广告年龄
-        df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
-
-        # 提取人群包层级
-        df["audience_tier"] = df["ad_name"].apply(_extract_audience_tier)
-
-        # 检测衰退信号
-        raw_dir = _MINI_DIR / "outputs" / "raw"
-        ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
-
-        decay_signals = _detect_decay_signals(
-            ad_ids=df["ad_id"].tolist(),
-            raw_dir=raw_dir,
-            ad_status_dir=ad_status_dir,
-            end_date=end_date
-        )
-
-        df = df.merge(decay_signals, on="ad_id", how="left")
-
-        # 填充缺失值
-        df["bid_increased_7d"] = df["bid_increased_7d"].fillna(False)
-        df["creative_changed_7d"] = df["creative_changed_7d"].fillna(False)
-        df["stable_spend_days_30d"] = df["stable_spend_days_30d"].fillna(0)
-
-        # 计算全体 动态ROI_7日均值 的均值(决策基准线)
-        f_roi_mean_all = df["动态ROI_7日均值"].mean()
-
-        # 构建上下文
-        context = {
-            "动态ROI_mean_all": f_roi_mean_all,
-            "min_ad_age_days": min_ad_age_days,
-            "min_daily_cost": min_daily_cost,
-            "roi_low_factor": roi_low_factor,
-            "no_spend_threshold": no_spend_threshold,
-            "stable_spend_threshold": stable_spend_threshold,
-        }
-
-        # 运行决策引擎
-        df = _run_decision_engine(df, context)
-
-        # 统计
-        total_ads = len(df)
-        pause_ads = (df["action"] == "pause").sum()
-        hold_ads = (df["action"] == "hold").sum()
-
-        dimension_counts = df["dimension"].value_counts().to_dict()
-
-        output_lines = [
-            f"决策完成(共 {total_ads} 个广告)",
-            f"  - 关停: {pause_ads} 个",
-            f"  - 保持: {hold_ads} 个",
-            "",
-            "维度分布:",
-        ]
-
-        for dim, count in dimension_counts.items():
-            output_lines.append(f"  - {dim}: {count} 个")
-
-        output_lines.extend([
-            "",
-            f"全体 动态ROI_7日均值 均值: {f_roi_mean_all:.4f}",
-            f"ROI 过低阈值: {f_roi_mean_all * roi_low_factor:.4f}",
-        ])
-
-        # 保存决策结果(临时 CSV,供 generate_report 使用)
-        decision_csv = _MINI_DIR / "outputs" / "decision_temp.csv"
-        decision_csv.parent.mkdir(parents=True, exist_ok=True)
-        df.to_csv(decision_csv, index=False)
-
-        return ToolResult(
-            title=f"决策引擎({total_ads}个广告)",
-            output="\n".join(output_lines),
-            metadata={
-                "total_ads": total_ads,
-                "pause_ads": pause_ads,
-                "hold_ads": hold_ads,
-                "dimension_counts": dimension_counts,
-                "动态ROI_mean_all": f_roi_mean_all,
-                "decision_csv": str(decision_csv),
-            },
-        )
-
-    except Exception as e:
-        logger.error("analyze_ads 失败: %s", e, exc_info=True)
-        return ToolResult(title="analyze_ads 失败", output=str(e))
-
-
 # ═══════════════════════════════════════════
 # 智能引擎工具 1:整理待评估广告数据
 # ═══════════════════════════════════════════
@@ -876,6 +310,7 @@ async def get_ads_for_review(
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         logger.info("读取人群包级别统计数据...")
         by_tier_stats = {}
+        by_tier_goal = {}
         try:
             # 读取 portfolio_summary JSON 文件
             portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
@@ -886,14 +321,15 @@ async def get_ads_for_review(
                 with open(portfolio_file, "r", encoding="utf-8") as f:
                     portfolio_data = json.load(f)
                 by_tier_stats = portfolio_data.get("by_audience_tier", {})
-                logger.info(f"✅ 从 {portfolio_file.name} 加载了 {len(by_tier_stats)} 个人群包的统计数据")
+                by_tier_goal = portfolio_data.get("by_tier_goal", {})
+                logger.info(f"✅ 从 {portfolio_file.name} 加载了 {len(by_tier_stats)} 个人群包 + {len(by_tier_goal)} 个tier+goal组的统计数据")
             else:
                 logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file},将使用全局均值兜底")
-                # 可以选择在这里调用 calculate_portfolio_summary 生成文件
-                # 但为了简化,我们先用空字典兜底
+                by_tier_goal = {}
         except Exception as e:
             logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
             by_tier_stats = {}
+            by_tier_goal = {}
 
         # 计算广告年龄
         df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
@@ -920,9 +356,9 @@ async def get_ads_for_review(
         roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
         roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
 
-        # 消耗中位数(供出价提升判断)
-        cost_series = df["cost_7d_avg"].dropna()
-        cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
+        # 加载调整历史(用于"持续低ROI升级关停"判断)
+        from guardrails import AdjustmentHistory
+        adjustment_history = AdjustmentHistory()
 
         # 分类(业务语言)
         zero_spend_ads = []      # 零消耗待关停
@@ -936,6 +372,8 @@ async def get_ads_for_review(
             bid_inc = bool(row.get("bid_increased_7d", False))
             creative_chg = bool(row.get("creative_changed_7d", False))
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
+            if pd.isna(stable_days):
+                stable_days = 0.0
             bid_amount = float(row.get("bid_amount", 0) or 0)
 
             # 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
@@ -959,28 +397,89 @@ async def get_ads_for_review(
                     })
                     continue
 
+            # 昨日消耗(用于关停消耗门槛:投手经验2.4 "当天消耗>300")
+            yesterday_cost = float(row.get("yesterday_cost", 0) or 0)
+
             # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
-            roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
+            # ★ 关停条件对齐投手经验2.4:需要昨日消耗≥300 且 广告年龄>3天
+            roi_low = (
+                (not pd.isna(f_roi))
+                and (f_roi < roi_mean * roi_review_factor)
+                and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST  # 昨日消耗≥300
+                and (ad_age is not None and ad_age > COLD_START_DAYS)  # 广告年龄>3天
+            )
             decay_signal = (
                 stable_days >= 7
                 and cost_7d_avg < 100
                 and (bid_inc or creative_chg)
             )
-            # 出价调整候选:高ROI低量(提价)或 ROI偏低(降价)
+
+            # ===== 裂变率 + CTR 数据(用于候选信号判断)=====
+            ad_fission = row.get("T0裂变系数_7日均值")
+            if ad_fission is None or pd.isna(ad_fission):
+                ad_fission = None
+            else:
+                ad_fission = float(ad_fission)
+
+            tier = _extract_audience_tier(str(row.get("ad_name", "")))
+            tier_stats = by_tier_stats.get(tier, {})
+            tier_fission_mean = tier_stats.get("fission_mean")
+
+            # CTR 数据
+            ad_view = float(row.get("view_count", 0) or 0)
+            ad_click = float(row.get("valid_click_count", 0) or 0)
+            ad_ctr = ad_click / ad_view if ad_view > 0 else None
+            tier_ctr_mean = tier_stats.get("ctr_mean")
+
+            # ===== 出价调整候选(投手经验对齐)=====
+            # 提价条件(投手经验1.1+1.2):
+            #   - ROI高于渠道均值5% + 裂变高于同类10% + CTR正常
+            #   - 消耗<1000(投手经验:"均值消耗小于1000")
+            #   - 仅4-7天可提价(投手经验1.2:">7天稳定期建议不调整出价")
             bid_up_candidate = (
                 (not pd.isna(f_roi))
-                and f_roi > roi_mean * params["BID_UP_ROI_FACTOR"]
-                and cost_7d_avg < cost_median * params["BID_UP_LOW_SPEND_FACTOR"]
+                and f_roi > roi_mean * params["BID_UP_ROI_FACTOR"]        # ROI高于渠道均值5%
+                and cost_7d_avg < BID_UP_MAX_SPEND                         # 消耗<1000(固定阈值)
                 and bid_amount > 0
+                and (ad_age is not None and ad_age <= EARLY_GROWTH_DAYS)   # ★ 仅4-7天可提价(≤3天已被冷启动排除)
+                and (tier_fission_mean is None or ad_fission is None       # 裂变高于同类均值10%(无数据时跳过)
+                     or ad_fission > tier_fission_mean * 1.10)
+                and (tier_ctr_mean is None or ad_ctr is None               # CTR不低于同类均值80%("正常"定义)
+                     or ad_ctr >= tier_ctr_mean * 0.80)
             ) if BID_ADJUSTMENT_ENABLED else False
+
+            # 降价:ROI低于渠道均值10% + 裂变低于同类10% + 消耗≥500元/天
             bid_down_candidate = (
                 (not pd.isna(f_roi))
-                and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]
-                and f_roi >= roi_mean * params["ROI_LOW_FACTOR"]
-                and cost_7d_avg >= 100
+                and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]      # ROI低于渠道均值10%
+                and f_roi >= roi_mean * params["ROI_LOW_FACTOR"]          # 但未达关停线
+                and cost_7d_avg >= BID_DOWN_MIN_SPEND                     # 消耗≥500元/天
                 and bid_amount > 0
+                and (tier_fission_mean is None or ad_fission is None      # 裂变低于同类均值10%(无数据时跳过)
+                     or ad_fission < tier_fission_mean * 0.90)
             ) if BID_ADJUSTMENT_ENABLED else False
 
+            # ===== 持续低ROI升级关停(投手经验2.4:"降价后持续低于均值就关停")=====
+            persistent_low_roi = False
+            if (
+                not roi_low  # 当前未达关停线(ROI在0.75~0.90之间)
+                and (not pd.isna(f_roi))
+                and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]  # ROI仍低于渠道均值10%
+                and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST  # 昨日消耗≥300
+                and (ad_age is not None and ad_age > COLD_START_DAYS)  # 年龄>3天
+            ):
+                last_bd_ts = adjustment_history.get_last_bid_down_ts(str(row["ad_id"]))
+                if last_bd_ts is not None:
+                    days_since_bd = (datetime.now() - last_bd_ts).days
+                    if days_since_bd >= 7:
+                        # 降价后≥7天ROI仍低 → 升级为关停候选
+                        persistent_low_roi = True
+                        roi_low = True  # 升级!
+                        logger.info(
+                            f"广告 {row['ad_id']} 降价后{days_since_bd}天ROI仍低"
+                            f"({f_roi:.4f}<{roi_mean * params['BID_DOWN_ROI_FACTOR']:.4f}),升级为关停候选"
+                        )
+
             # 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
             scale_up_candidate = (
                 ad_age is not None
@@ -991,6 +490,18 @@ async def get_ads_for_review(
                 and f_roi >= roi_mean * 0.9  # ROI正常(≥均值的90%)
             )
 
+            # ===== 消耗稳定性前置门控(决策树:成熟期+不稳定→observe)=====
+            if ad_age is not None and ad_age > EARLY_GROWTH_DAYS and stable_days < 7:
+                # 成熟期广告但消耗不稳定:清除负向信号,不进入降价/关停评估
+                if roi_low or decay_signal or bid_down_candidate:
+                    logger.debug(
+                        f"广告 {row['ad_id']} 成熟期({ad_age}天)但消耗不稳定(稳定天数{stable_days}<7),"
+                        f"清除负向信号: roi_low={roi_low}, decay={decay_signal}, bid_down={bid_down_candidate}"
+                    )
+                    roi_low = False
+                    decay_signal = False
+                    bid_down_candidate = False
+
             # ===== 年龄保护(第一优先级)=====
             # 无论是否满足候选条件,年龄保护都是第一层判断
             age_protected_skip = False  # 标记是否被年龄保护排除
@@ -1056,6 +567,10 @@ async def get_ads_for_review(
                     "bid_amount": round(bid_amount, 2),
                     "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
                     "scale_up_candidate": scale_up_candidate,  # 新增:扩量候选标记
+                    # ===== 广告自身指标(供LLM对比同类基准) =====
+                    "ad_fission": round(ad_fission, 4) if ad_fission is not None else None,
+                    "ad_ctr": round(ad_ctr, 4) if ad_ctr is not None else None,
+                    "yesterday_cost": round(yesterday_cost, 2),
                 }
 
                 # ===== 新增:添加 audience_tier 和 roi_valid_days =====
@@ -1075,6 +590,17 @@ async def get_ads_for_review(
                 ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
                 ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
 
+                # ===== 新增:CTR + 同类均值出价(基于 tier+goal 分组)=====
+                ad_dict["tier_ctr_mean"] = tier_stats.get("ctr_mean")
+                ad_goal = str(row.get("广告优化目标", ""))
+                tier_goal_key = f"{tier}_{ad_goal}"
+                tier_goal_stats = by_tier_goal.get(tier_goal_key, tier_stats)
+                tier_bid_mean = tier_goal_stats.get("bid_mean")
+
+                ad_dict["tier_bid_mean"] = tier_bid_mean  # 同类(tier+goal)均值出价
+                ad_dict["bid_up_target_min"] = round(tier_bid_mean * 1.05, 4) if tier_bid_mean else None
+                ad_dict["bid_up_target_max"] = round(tier_bid_mean * 1.10, 4) if tier_bid_mean else None
+
                 # 计算动态阈值(供LLM参考)
                 tier_roi_p50 = tier_stats.get("roi_p50", roi_mean)  # 兜底用全局均值
 
@@ -1118,6 +644,23 @@ async def get_ads_for_review(
                     else:
                         ad_dict["high_burn_alert"] = False
 
+                # ===== 调幅参数分离(基于候选类型)=====
+                if bid_up_candidate:
+                    ad_dict["bid_change_min_pct"] = BID_UP_MIN_PCT    # 0.05
+                    ad_dict["bid_change_max_pct"] = BID_UP_MAX_PCT    # 0.10
+                elif bid_down_candidate:
+                    ad_dict["bid_change_min_pct"] = BID_DOWN_MIN_PCT  # 0.03
+                    ad_dict["bid_change_max_pct"] = BID_DOWN_MAX_PCT  # 0.05
+                else:
+                    # 兜底:roi_low/decay/scale_up 等非出价候选,LLM 仍可能建议调价
+                    ad_dict["bid_change_min_pct"] = BID_CHANGE_MIN_PCT  # 0.03
+                    ad_dict["bid_change_max_pct"] = BID_CHANGE_MAX_PCT  # 0.10
+
+                # ★ 持续低ROI升级标记(告知LLM这是升级后的关停候选)
+                if persistent_low_roi:
+                    ad_dict["persistent_low_roi"] = True
+                    ad_dict["recommendation_hint"] = "该广告降价后≥7天ROI仍低于渠道均值,建议关停"
+
                 need_review_ads.append(ad_dict)
                 continue
 
@@ -1138,19 +681,20 @@ async def get_ads_for_review(
                 "p50": round(roi_p50, 4),
                 "p75": round(roi_p75, 4),
                 "p90": round(roi_p90, 4),
-                "cost_7d_avg_median": round(cost_median, 2),
             },
             "bid_adjustment": {
                 "enabled": BID_ADJUSTMENT_ENABLED,
                 "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
                 "bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
-                "low_spend_line": round(cost_median * params["BID_UP_LOW_SPEND_FACTOR"], 2),
+                "bid_up_max_spend": BID_UP_MAX_SPEND,
+                "roi_low_min_yesterday_cost": ROI_LOW_MIN_YESTERDAY_COST,
             },
             "thresholds_used": {
                 "ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
                 "BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
                 "BID_UP_ROI_FACTOR": params["BID_UP_ROI_FACTOR"],
-                "BID_UP_LOW_SPEND_FACTOR": params["BID_UP_LOW_SPEND_FACTOR"],
+                "BID_UP_MAX_SPEND": BID_UP_MAX_SPEND,
+                "ROI_LOW_MIN_YESTERDAY_COST": ROI_LOW_MIN_YESTERDAY_COST,
                 "roi_mean": round(roi_mean, 4),
                 "pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
                 "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
@@ -1522,10 +1066,8 @@ async def query_ad_detail(
         # 全局 ROI 分布
         roi_series = df["动态ROI_7日均值"].dropna()
         roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
-        cost_series = df["cost_7d_avg"].dropna()
-        cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
 
-        roi_low_line = roi_mean * ROI_LOW_FACTOR if "ROI_LOW_FACTOR" in dir() else roi_mean * 0.5
+        roi_low_line = roi_mean * ROI_LOW_FACTOR
         bid_down_line = roi_mean * BID_DOWN_ROI_FACTOR
         bid_up_line = roi_mean * BID_UP_ROI_FACTOR
 
@@ -1563,10 +1105,11 @@ async def query_ad_detail(
         # 全局上下文
         global_context = {
             "全体动态ROI均值": round(roi_mean, 4),
-            "ROI关停线": round(roi_mean * 0.5, 4),
+            "ROI关停线": round(roi_low_line, 4),
             "ROI降价线": round(bid_down_line, 4),
             "ROI提价线": round(bid_up_line, 4),
-            "全体消耗中位数": round(cost_median, 2),
+            "提价消耗上限": BID_UP_MAX_SPEND,
+            "关停消耗门槛(昨日)": ROI_LOW_MIN_YESTERDAY_COST,
         }
 
         result = {

+ 73 - 4
examples/auto_put_ad_mini/tools/guardrails.py

@@ -1,10 +1,11 @@
 """
 安全护栏引擎 — auto_put_ad_mini
 
-6 道护栏按顺序执行:
+7 道护栏按顺序执行:
   1. ColdStartGuardrail    — 冷启动保护
   2. DataFreshnessGuardrail — 数据新鲜度校验
-  3. BidBoundaryGuardrail  — 出价边界钳位
+  3. BidBoundaryGuardrail  — 出价边界钳位(0.05~1.00元)
+  3.5 BidRangeGuardrail    — 调幅范围钳位(提价5-10%, 降价3-5%)
   4. RateLimitGuardrail    — 频率限制(每日次数/间隔/累计调幅)
   5. DailyOpsCapGuardrail  — 每日操作总量上限
   6. DryRunGuardrail       — 干运行模式
@@ -39,6 +40,10 @@ from config import (
     CAUTIOUS_DAYS,
     BID_FLOOR_YUAN,
     BID_CEILING_YUAN,
+    BID_UP_MIN_PCT,
+    BID_UP_MAX_PCT,
+    BID_DOWN_MIN_PCT,
+    BID_DOWN_MAX_PCT,
     MAX_ADJUSTMENTS_PER_AD_PER_DAY,
     MIN_ADJUSTMENT_INTERVAL_HOURS,
     MAX_DAILY_CUMULATIVE_CHANGE_PCT,
@@ -119,8 +124,8 @@ class AdjustmentHistory:
         })
         self._data[ad_key]["last_ts"] = now
 
-        # 只保留最近 7 天的记录
-        cutoff = (datetime.now() - timedelta(days=7)).isoformat()
+        # 只保留最近 14 天的记录(扩展:支持"持续低ROI升级关停"需查7天前降价记录)
+        cutoff = (datetime.now() - timedelta(days=14)).isoformat()
         self._data[ad_key]["adjustments"] = [
             a for a in self._data[ad_key]["adjustments"]
             if a.get("ts", "") >= cutoff
@@ -138,6 +143,19 @@ class AdjustmentHistory:
                 count += 1
         return count
 
+    def get_last_bid_down_ts(self, ad_id: str) -> Optional[datetime]:
+        """获取某广告最近一次 bid_down 的时间(用于"持续低ROI升级关停"判断)。"""
+        record = self._data.get(str(ad_id), {})
+        adjustments = record.get("adjustments", [])
+        bid_downs = [a for a in adjustments if a.get("action") == "bid_down"]
+        if bid_downs:
+            last = max(bid_downs, key=lambda a: a.get("ts", ""))
+            try:
+                return datetime.fromisoformat(last["ts"])
+            except ValueError:
+                return None
+        return None
+
 
 # ═══════════════════════════════════════════
 # 护栏检查结果
@@ -303,6 +321,56 @@ class BidBoundaryGuardrail(Guardrail):
         return GuardrailResult(status="approved", reason="")
 
 
+# ═══════════════════════════════════════════
+# 护栏 3.5: 调幅范围钳位
+# ═══════════════════════════════════════════
+
+
+class BidRangeGuardrail(Guardrail):
+    """调幅范围钳位:bid_up 钳位到 [5%, 10%],bid_down 钳位到 [-5%, -3%]。"""
+
+    @property
+    def name(self) -> str:
+        return "调幅范围"
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        if action not in ("bid_up", "bid_down"):
+            return GuardrailResult(status="approved", reason="")
+
+        change_pct = row.get("recommended_change_pct")
+        if change_pct is None or change_pct == "":
+            return GuardrailResult(status="approved", reason="")
+
+        change_pct = float(change_pct)
+        current_bid = float(row.get("current_bid", 0) or 0)
+
+        if action == "bid_up":
+            # 提价:钳位到 [BID_UP_MIN_PCT, BID_UP_MAX_PCT]
+            clamped = max(BID_UP_MIN_PCT, min(BID_UP_MAX_PCT, change_pct))
+            if abs(clamped - change_pct) > 0.001:
+                new_bid = round(current_bid * (1 + clamped), 2) if current_bid > 0 else None
+                return GuardrailResult(
+                    status="modified",
+                    reason=f"提价调幅从{change_pct*100:.1f}%钳位至{clamped*100:.1f}%(范围{BID_UP_MIN_PCT*100:.0f}%-{BID_UP_MAX_PCT*100:.0f}%)",
+                    modified_change_pct=round(clamped, 4),
+                    modified_bid=new_bid,
+                )
+        elif action == "bid_down":
+            # 降价:change_pct 应为负数,钳位到 [-BID_DOWN_MAX_PCT, -BID_DOWN_MIN_PCT]
+            clamped = min(-BID_DOWN_MIN_PCT, max(-BID_DOWN_MAX_PCT, change_pct))
+            if abs(clamped - change_pct) > 0.001:
+                new_bid = round(current_bid * (1 + clamped), 2) if current_bid > 0 else None
+                return GuardrailResult(
+                    status="modified",
+                    reason=f"降价调幅从{change_pct*100:.1f}%钳位至{clamped*100:.1f}%(范围-{BID_DOWN_MAX_PCT*100:.0f}%~-{BID_DOWN_MIN_PCT*100:.0f}%)",
+                    modified_change_pct=round(clamped, 4),
+                    modified_bid=new_bid,
+                )
+
+        return GuardrailResult(status="approved", reason="")
+
+
 # ═══════════════════════════════════════════
 # 护栏 4: 频率限制
 # ═══════════════════════════════════════════
@@ -458,6 +526,7 @@ def _run_guardrails(
         ColdStartGuardrail(),
         DataFreshnessGuardrail(),
         BidBoundaryGuardrail(),
+        BidRangeGuardrail(),
         RateLimitGuardrail(),
         DailyOpsCapGuardrail(),
         DryRunGuardrail() if dry_run or DRY_RUN_MODE else None,

+ 41 - 1
examples/auto_put_ad_mini/tools/portfolio_metrics.py

@@ -20,6 +20,7 @@ from datetime import datetime, timedelta
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Tuple
 
+import numpy as np
 import pandas as pd
 
 from agent.tools import tool
@@ -127,6 +128,30 @@ def _describe_group(df: pd.DataFrame) -> Dict[str, Any]:
                 "fission_p50": _round_or_none(float(fission_series.quantile(0.50))),
             }
 
+    # ===== 新增:CTR 统计 =====
+    ctr_stats = {"ctr_mean": None, "ctr_p50": None}
+    if "valid_click_count" in df.columns and "view_count" in df.columns:
+        click = pd.to_numeric(df["valid_click_count"], errors="coerce").fillna(0)
+        view = pd.to_numeric(df["view_count"], errors="coerce").fillna(0)
+        ctr_series = (click / view).replace([np.inf, -np.inf], np.nan).dropna()
+        ctr_series = ctr_series[ctr_series > 0]
+        if not ctr_series.empty:
+            ctr_stats = {
+                "ctr_mean": round(float(ctr_series.mean()), 6),
+                "ctr_p50": round(float(ctr_series.quantile(0.50)), 6),
+            }
+
+    # ===== 新增:出价统计 =====
+    bid_stats = {"bid_mean": None, "bid_p50": None}
+    if "bid_amount" in df.columns:
+        bid_series = pd.to_numeric(df["bid_amount"], errors="coerce").dropna()
+        bid_series = bid_series[bid_series > 0]
+        if not bid_series.empty:
+            bid_stats = {
+                "bid_mean": round(float(bid_series.mean()), 4),
+                "bid_p50": round(float(bid_series.quantile(0.50)), 4),
+            }
+
     # 稳定消耗比例:stable_spend_days_30d >= 7 视为稳定
     stable_pct = 0.0
     if "stable_spend_days_30d" in df.columns and ad_count > 0:
@@ -139,7 +164,9 @@ def _describe_group(df: pd.DataFrame) -> Dict[str, Any]:
         "daily_cost_avg": round(daily_cost_avg, 2),
         "daily_revenue_avg": round(daily_revenue_avg, 2),
         **roi_stats,
-        **fission_stats,  # 新增
+        **fission_stats,
+        **ctr_stats,
+        **bid_stats,
         "stable_ads_pct": round(stable_pct, 4),
     }
 
@@ -387,6 +414,18 @@ async def calculate_portfolio_summary(
             for tier, group in df.groupby("audience_tier"):
                 by_tier[str(tier)] = _describe_group(group)
 
+        # 按 (audience_tier, 广告优化目标) 双键分组 — 用于同类+同转化目标的均值出价
+        by_tier_goal: Dict[str, Any] = {}
+        goal_col = None
+        for candidate in ("广告优化目标", "optimization_goal"):
+            if candidate in df.columns:
+                goal_col = candidate
+                break
+        if "audience_tier" in df.columns and goal_col:
+            for (tier, goal), group in df.groupby(["audience_tier", goal_col]):
+                key = f"{tier}_{goal}"
+                by_tier_goal[key] = _describe_group(group)
+
         # 全局
         global_stats = _describe_group(df)
 
@@ -401,6 +440,7 @@ async def calculate_portfolio_summary(
             "source_csv": str(csv_path),
             "by_account": by_account,
             "by_audience_tier": by_tier,
+            "by_tier_goal": by_tier_goal,
             "global": global_stats,
             "by_date": by_date,
             "market_signal": market_signal,

+ 1 - 118
examples/auto_put_ad_mini/tools/report_generator.py

@@ -193,7 +193,7 @@ async def generate_report(
     生成决策报告。
 
     Args:
-        decision_csv: 决策结果 CSV 路径(analyze_ads 输出)
+        decision_csv: 决策结果 CSV 路径(apply_decisions 输出)
         end_date: 结束日期
 
     Returns:
@@ -262,120 +262,3 @@ async def generate_report(
         return ToolResult(title="报告生成失败", output=str(e))
 
 
-# ═══════════════════════════════════════════
-# 双引擎对比工具
-# ═══════════════════════════════════════════
-
-
-@tool(description="对比规则引擎与智能引擎的决策差异")
-async def compare_decisions(
-    ctx: ToolContext,
-    end_date: str = "yesterday",
-) -> ToolResult:
-    """
-    加载同日期的规则引擎和智能引擎决策结果,输出对比报告。
-
-    对比标签:
-      - agree:两个引擎一致(都 pause 或都 hold)
-      - rule_only_pause:只有规则引擎关停
-      - llm_only_pause:只有智能引擎关停
-      - disagree:决策不同(其他情况)
-
-    Args:
-        end_date: 结束日期(YYYYMMDD 或 "yesterday")
-    """
-    try:
-        if end_date == "yesterday":
-            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
-
-        rule_path = _REPORTS_DIR / f"decision_{end_date}.csv"
-        llm_path = _REPORTS_DIR / f"llm_decisions_{end_date}.csv"
-
-        if not rule_path.exists():
-            return ToolResult(
-                title="compare_decisions",
-                output=f"规则引擎结果不存在: {rule_path}",
-            )
-        if not llm_path.exists():
-            return ToolResult(
-                title="compare_decisions",
-                output=f"智能引擎结果不存在: {llm_path}",
-            )
-
-        df_rule = pd.read_csv(rule_path)[["ad_id", "action", "dimension", "reason"]].copy()
-        df_rule.columns = ["ad_id", "rule_action", "rule_dimension", "rule_reason"]
-
-        df_llm = pd.read_csv(llm_path)[["ad_id", "action", "dimension", "reason", "confidence"]].copy()
-        df_llm.columns = ["ad_id", "llm_action", "llm_dimension", "llm_reason", "llm_confidence"]
-
-        # 外连接合并
-        df = pd.merge(df_rule, df_llm, on="ad_id", how="outer")
-
-        # 填充缺失
-        df["rule_action"] = df["rule_action"].fillna("no_data")
-        df["llm_action"] = df["llm_action"].fillna("no_data")
-
-        # 打标签
-        def _label(row):
-            r = row["rule_action"]
-            l = row["llm_action"]
-            if r == l:
-                return "agree"
-            if r == "pause" and l != "pause":
-                return "rule_only_pause"
-            if l == "pause" and r != "pause":
-                return "llm_only_pause"
-            return "disagree"
-
-        df["comparison"] = df.apply(_label, axis=1)
-
-        # 保存对比报告
-        _REPORTS_DIR.mkdir(parents=True, exist_ok=True)
-        out_path = _REPORTS_DIR / f"comparison_{end_date}.csv"
-        df.to_csv(out_path, index=False, encoding="utf-8-sig")
-
-        # 统计摘要
-        total = len(df)
-        counts = df["comparison"].value_counts().to_dict()
-        agree = counts.get("agree", 0)
-        rule_only = counts.get("rule_only_pause", 0)
-        llm_only = counts.get("llm_only_pause", 0)
-        disagree = counts.get("disagree", 0)
-
-        rule_pause = (df["rule_action"] == "pause").sum()
-        llm_pause = (df["llm_action"] == "pause").sum()
-        agree_rate = round(agree / total * 100, 1) if total > 0 else 0.0
-
-        summary = (
-            f"双引擎对比报告: {out_path}\n\n"
-            f"总广告数: {total}\n"
-            f"一致率: {agree_rate}%({agree}/{total})\n\n"
-            f"规则引擎关停: {rule_pause} 个\n"
-            f"智能引擎关停: {llm_pause} 个\n\n"
-            f"分类明细:\n"
-            f"  agree(两者一致): {agree}\n"
-            f"  rule_only_pause(仅规则关停): {rule_only}\n"
-            f"  llm_only_pause(仅智能关停): {llm_only}\n"
-            f"  disagree(其他差异): {disagree}"
-        )
-
-        return ToolResult(
-            title=f"双引擎对比(一致率 {agree_rate}%)",
-            output=summary,
-            metadata={
-                "csv_path": str(out_path),
-                "total": total,
-                "agree_rate": agree_rate,
-                "rule_pause": int(rule_pause),
-                "llm_pause": int(llm_pause),
-                "agree": agree,
-                "rule_only_pause": rule_only,
-                "llm_only_pause": llm_only,
-                "disagree": disagree,
-                "end_date": end_date,
-            },
-        )
-
-    except Exception as e:
-        logger.error("compare_decisions 失败: %s", e, exc_info=True)
-        return ToolResult(title="compare_decisions 失败", output=str(e))

+ 13 - 6
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -268,9 +268,12 @@ def _calculate_yesterday_roi(ad_df: pd.DataFrame, yesterday: str) -> pd.DataFram
         np.nan
     )
 
-    # 合并回原 DataFrame(只保留昨日的 ROI)
+    # 同时保留昨日消耗(用于关停消耗门槛判断,投手经验2.4)
+    yesterday_df["yesterday_cost"] = yesterday_df["cost"]
+
+    # 合并回原 DataFrame(保留昨日的 ROI + 昨日消耗)
     ad_df = ad_df.merge(
-        yesterday_df[["ad_id", "yesterday_roi"]],
+        yesterday_df[["ad_id", "yesterday_roi", "yesterday_cost"]],
         on="ad_id",
         how="left"
     )
@@ -436,10 +439,14 @@ async def calculate_roi_metrics(
         summary_30d = _calculate_30d_summary(ad_df)
 
         # Step 6: 合并所有指标(取最新一天的广告属性)
-        latest_ad = ad_df[ad_df["date"] == end_date_str][[
+        latest_cols = [
             "ad_id", "account_id", "ad_name", "create_time",
-            "configured_status", "bid_amount", "creative_count"
-        ]].copy()
+            "configured_status", "bid_amount", "creative_count",
+            "yesterday_roi", "yesterday_cost",  # 昨日ROI+昨日消耗(投手经验2.4关停门槛)
+        ]
+        # 只取存在的列(yesterday_cost 可能在 _calculate_yesterday_roi 中添加)
+        latest_cols = [c for c in latest_cols if c in ad_df.columns]
+        latest_ad = ad_df[ad_df["date"] == end_date_str][latest_cols].copy()
 
         result_df = latest_ad.merge(summary_7d, on="ad_id", how="left")
         result_df = result_df.merge(summary_30d, on="ad_id", how="left")
@@ -470,7 +477,7 @@ async def calculate_roi_metrics(
         # 计算全体 动态ROI_7日均值 的均值(供决策引擎做相对比较)
         f7_7d_mean_all = result_df["动态ROI_7日均值"].mean() if "动态ROI_7日均值" in result_df.columns else float("nan")
 
-        # 保存指标 CSV(供 analyze_ads 读取)
+        # 保存指标 CSV(供 get_ads_for_review 读取)
         metrics_dir = _MINI_DIR / "outputs"
         metrics_dir.mkdir(parents=True, exist_ok=True)
         metrics_csv = metrics_dir / f"metrics_{end_date_str}.csv"

+ 11 - 50
examples/auto_put_ad_mini/verify_decision.py

@@ -38,7 +38,6 @@ from examples.auto_put_ad_mini.tools.roi_calculator import (
 from examples.auto_put_ad_mini.tools.ad_decision import (
     _extract_audience_tier,
     _calculate_ad_age_days,
-    _run_decision_engine,
     _detect_decay_signals,
 )
 from examples.auto_put_ad_mini.tools.report_generator import (
@@ -137,67 +136,29 @@ df_final["ad_age_days"] = df_final["create_time"].apply(_calculate_ad_age_days)
 
 print(f"   最终数据: {len(df_final)} 个广告, {len(df_final.columns)} 列")
 
-# ⑨ 运行决策引擎
-print("\n⑨ 运行三维度决策引擎...")
+# ⑨ 数据统计(规则引擎已废弃,仅输出指标统计)
+print("\n⑨ 数据统计...")
 f_roi_mean_all = df_final["动态ROI_7日均值"].mean()
 print(f"   全体 动态ROI_7日均值 均值: {f_roi_mean_all:.4f}")
+print(f"   关停线(均值×{ROI_LOW_FACTOR}): {f_roi_mean_all * ROI_LOW_FACTOR:.4f}")
+print(f"   广告总数: {len(df_final)}")
 
-context = {
-    "动态ROI_mean_all": f_roi_mean_all,
-    "min_ad_age_days": MIN_AD_AGE_DAYS,
-    "min_daily_cost": MIN_DAILY_COST,
-    "roi_low_factor": ROI_LOW_FACTOR,
-    "no_spend_threshold": NO_SPEND_THRESHOLD,
-    "stable_spend_threshold": STABLE_SPEND_THRESHOLD,
-}
-
-df_decision = _run_decision_engine(df_final, context)
-print(f"   决策完成: {len(df_decision)} 个广告")
-
-# ⑩ 统计
-print("\n⑩ 决策统计:")
-action_counts = df_decision["action"].value_counts()
-for action, count in action_counts.items():
-    print(f"   {action}: {count} 个")
-
-dimension_counts = df_decision["dimension"].value_counts()
-print("\n   命中维度:")
-for dim, count in dimension_counts.items():
-    print(f"   {dim}: {count} 个")
-
-# ⑪ 生成报告
-print("\n⑪ 生成报告...")
+# ⑩ 生成 metrics CSV(供智能引擎使用)
+print("\n⑩ 生成 metrics CSV...")
 reports_dir.mkdir(parents=True, exist_ok=True)
 
-# 选择输出列
-cols = [c for c in OUTPUT_COLUMNS if c in df_decision.columns]
-df_out = df_decision[cols].copy()
+cols = [c for c in OUTPUT_COLUMNS if c in df_final.columns]
+df_out = df_final[cols].copy()
 
-# 排序:关停在前
-df_out["_sort"] = (df_out["action"] == "pause").astype(int) * -1
-df_out = df_out.sort_values(["_sort", "cost_7d_total"], ascending=[True, False])
-df_out.drop(columns=["_sort"], inplace=True)
-
-# CSV
-csv_path = reports_dir / f"decision_{yesterday}_verify.csv"
+csv_path = reports_dir / f"metrics_{yesterday}_verify.csv"
 df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
 print(f"   CSV: {csv_path}")
 
 # XLSX
-xlsx_path = reports_dir / f"decision_{yesterday}_verify.xlsx"
+xlsx_path = reports_dir / f"metrics_{yesterday}_verify.xlsx"
 _write_xlsx_with_format(df_out, xlsx_path)
 print(f"   XLSX: {xlsx_path}")
 
 print("\n=" * 60)
-print("验证完成!")
+print("验证完成!(注:规则引擎已废弃,决策请使用智能引擎 execute_once.py)")
 print("=" * 60)
-
-# 显示前 10 个关停广告
-pause_ads = df_out[df_out["action"] == "pause"].head(10)
-if not pause_ads.empty:
-    print("\n前 10 个关停广告:")
-    for _, row in pause_ads.iterrows():
-        print(f"  {int(row['ad_id'])}: {row['ad_name'][:30]}")
-        print(f"    维度: {row['dimension']}, 理由: {row['reason']}")
-        print(f"    动态ROI: {row.get('动态ROI', 'N/A')}, 7日消耗: {row.get('cost_7d_avg', 0):.2f}元")
-        print()