Quellcode durchsuchen

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>
刘立冬 vor 3 Wochen
Ursprung
Commit
0e4f248ebc

+ 15 - 16
examples/auto_put_ad_mini/config.py

@@ -2,7 +2,7 @@
 广告智能决策引擎配置 — auto_put_ad_mini
 广告智能决策引擎配置 — auto_put_ad_mini
 
 
 运营可直接修改此文件调整决策参数。
 运营可直接修改此文件调整决策参数。
-当前模式:规则判断 + 智能判断
+当前模式:智能判断
   - 基于 f_7日动态ROI 的精细化决策
   - 基于 f_7日动态ROI 的精细化决策
   - AI 推理结合领域知识
   - AI 推理结合领域知识
   - 三级分类:零消耗待关停(规则)+ 待优化评估(智能)+ 正常运行(规则)
   - 三级分类:零消耗待关停(规则)+ 待优化评估(智能)+ 正常运行(规则)
@@ -22,10 +22,6 @@ except ImportError:
 # Agent 运行配置
 # Agent 运行配置
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 
 
-# 引擎开关
-USE_RULE_ENGINE = False  # True=使用规则引擎, False=只用智能引擎
-USE_AI_ENGINE = True     # True=使用智能引擎, False=只用规则引擎
-
 MAIN_CONFIG = RunConfig(
 MAIN_CONFIG = RunConfig(
     model="qwen/qwen3.5-plus-02-15",
     model="qwen/qwen3.5-plus-02-15",
     temperature=0.3,
     temperature=0.3,
@@ -48,9 +44,6 @@ MAIN_CONFIG = RunConfig(
         "check_approval_status",
         "check_approval_status",
         # 飞书文档(报告导入 & 分享):
         # 飞书文档(报告导入 & 分享):
         "import_to_feishu",
         "import_to_feishu",
-        # 规则引擎工具(暂时禁用):
-        # "analyze_ads",
-        # "compare_decisions",
     ],
     ],
     skills=["roi-strategy", "guardrail-rules"],
     skills=["roi-strategy", "guardrail-rules"],
     # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking
     # 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_DAILY_COST = 100  # 日消耗 >= 100元才参与 ROI 计算
 MIN_AD_AGE_DAYS = 3  # 广告创建 >= 3天才参与决策(与 min_periods 对齐)
 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元 → 关停
 NO_SPEND_THRESHOLD = 10  # 7日消耗均值 < 10元 → 关停
 STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 
 
@@ -86,13 +79,18 @@ STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 # 出价调整配置
 # 出价调整配置
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 BID_ADJUSTMENT_ENABLED = True
 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天):极度保护,几乎不干预
 COLD_START_DAYS = 3             # 冷启动期(≤3天):极度保护,几乎不干预
@@ -106,6 +104,7 @@ CAUTIOUS_DAYS = EARLY_GROWTH_DAYS  # 兼容旧代码
 # 高燃烧预警配置
 # 高燃烧预警配置
 HIGH_BURN_AGE_THRESHOLD = 3     # 广告年龄>3天才检查
 HIGH_BURN_AGE_THRESHOLD = 3     # 广告年龄>3天才检查
 HIGH_BURN_COST_THRESHOLD = 300  # 昨日消耗>300元触发预警
 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.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.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.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.execution_engine import execute_decisions, check_execution_feedback
 from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status
 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": [
     "allowed_tools": [
       "fetch_creative_data",
       "fetch_creative_data",
       "calculate_roi_metrics",
       "calculate_roi_metrics",
-      "analyze_ads",
       "generate_report"
       "generate_report"
     ],
     ],
     "skills": ["roi-strategy"]
     "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.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.roi_calculator import calculate_roi_metrics
 from examples.auto_put_ad_mini.tools.ad_decision import (
 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,
     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.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.execution_engine import execute_decisions, check_execution_feedback
 from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status
 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 低且消耗高 → **积极降价**(优化成本)
 - 如果 ROI 低且消耗高 → **积极降价**(优化成本)
 - 如果近期换过创意 → **观察 2-3 天再降价**
 - 如果近期换过创意 → **观察 2-3 天再降价**
 - 如果近期已降价 → **避免频繁调整**
 - 如果近期已降价 → **避免频繁调整**
+- 降价时同时建议运营调整素材方向(不自动执行,仅在理由中输出)
 
 
 **降价幅度**:
 **降价幅度**:
 - 低于同类 10-12% → 降价 3-5%
 - 低于同类 10-12% → 降价 3-5%
@@ -159,17 +160,26 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 **什么时候考虑关停**:
 **什么时候考虑关停**:
 当广告表现远低于同类,明显低效时
 当广告表现远低于同类,明显低效时
 
 
+**前提条件**(硬规则已在代码中实现):
+- 昨日消耗 ≥ 300 元(消耗太低的广告数据不可靠)
+- 广告年龄 > 3 天(冷启动期不评估关停)
+- 不满足以上条件的低 ROI 广告会被归入 hold
+
 **参考标准**:
 **参考标准**:
 - ROI 低于同类中位数 **25-30%** 或更多
 - ROI 低于同类中位数 **25-30%** 或更多
 - 已处于同类最差水平
 - 已处于同类最差水平
 
 
+**持续低 ROI 升级关停**:
+- 如果广告之前被降价过,且降价后 ≥ 7 天 ROI 仍低于渠道均值 → 自动升级为关停候选
+- 这类广告会带有 `persistent_low_roi=True` 标记
+
 **重要**:
 **重要**:
 以下情况已被硬规则直接关停,不会到达你这里:
 以下情况已被硬规则直接关停,不会到达你这里:
 - 7 天无消耗(< 10 元)
 - 7 天无消耗(< 10 元)
 - 预算耗尽
 - 预算耗尽
 - 明显衰退(提价后仍低)
 - 明显衰退(提价后仍低)
 
 
-你需要判断的是:**ROI 低但有一定消耗的广告**
+你需要判断的是:**ROI 低但有一定消耗(昨日≥300元)的广告**
 
 
 ---
 ---
 
 
@@ -226,18 +236,19 @@ LLM建议: bid_down -5%
 
 
 ---
 ---
 
 
-### 第3段:成熟期(>7天)— 可全面调控
+### 第3段:成熟期(>7天)— 可降价/关停/扩量,不提价
 
 
 **特征**:
 **特征**:
 - 数据充分(≥7天),ROI稳定
 - 数据充分(≥7天),ROI稳定
 - 系统学习完成
 - 系统学习完成
-- 可全面调控
+- 稳定期不调出价,通过新增广告/创意拿消耗(投手经验1.2)
 
 
 **策略**:
 **策略**:
 - ✅ **可降价**,最大5%(`max_bid_down_pct=0.05`)— 决策树明确3-5%上限
 - ✅ **可降价**,最大5%(`max_bid_down_pct=0.05`)— 决策树明确3-5%上限
-- ✅ **可提价**,5-10%
+- ❌ **不提价**(稳定期不调出价,通过新增广告/创意扩量)
+- ✅ **可扩量**(scale_up,建议新增广告或创意拿消耗)
 - ✅ **可关停**(ROI低于同类中位数25-30%)
 - ✅ **可关停**(ROI低于同类中位数25-30%)
-- ✅ 所有动作均可使用
+- ✅ 可降价、观察、调整素材方向
 
 
 **数据标识**:
 **数据标识**:
 - `age_segment`: `mature`
 - `age_segment`: `mature`

+ 5 - 6
examples/auto_put_ad_mini/strategy_params.json

@@ -1,12 +1,11 @@
 {
 {
   "version": "1.0",
   "version": "1.0",
-  "last_updated": "2026-04-17",
-  "updated_by": "system_init",
+  "last_updated": "2026-04-20",
+  "updated_by": "decision_tree_alignment",
   "params": {
   "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": []
   "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.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.roi_calculator import calculate_roi_metrics
 from examples.auto_put_ad_mini.tools.ad_decision import (
 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,
     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.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.execution_engine import execute_decisions, check_execution_feedback
 from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status
 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 logging
 import sys
 import sys
-from abc import ABC, abstractmethod
-from dataclasses import dataclass
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, List, Optional, Tuple
 from typing import Dict, List, Optional, Tuple
@@ -36,9 +28,14 @@ from config import (
     BID_ADJUSTMENT_ENABLED,
     BID_ADJUSTMENT_ENABLED,
     BID_DOWN_ROI_FACTOR,
     BID_DOWN_ROI_FACTOR,
     BID_UP_ROI_FACTOR,
     BID_UP_ROI_FACTOR,
-    BID_UP_LOW_SPEND_FACTOR,
+    BID_UP_MAX_SPEND,
     BID_CHANGE_MIN_PCT,
     BID_CHANGE_MIN_PCT,
     BID_CHANGE_MAX_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_FLOOR_YUAN,
     BID_CEILING_YUAN,
     BID_CEILING_YUAN,
     COLD_START_DAYS,       # ≤3天:冷启动期(极度保护)
     COLD_START_DAYS,       # ≤3天:冷启动期(极度保护)
@@ -47,6 +44,7 @@ from config import (
     HIGH_BURN_AGE_THRESHOLD,
     HIGH_BURN_AGE_THRESHOLD,
     HIGH_BURN_COST_THRESHOLD,
     HIGH_BURN_COST_THRESHOLD,
     ROI_LOW_FACTOR,
     ROI_LOW_FACTOR,
+    ROI_LOW_MIN_YESTERDAY_COST,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -74,7 +72,6 @@ def _load_strategy_params():
         "ROI_LOW_FACTOR": ROI_LOW_FACTOR,
         "ROI_LOW_FACTOR": ROI_LOW_FACTOR,
         "BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
         "BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
         "BID_UP_ROI_FACTOR": BID_UP_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
         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
     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:整理待评估广告数据
 # 智能引擎工具 1:整理待评估广告数据
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
@@ -876,6 +310,7 @@ async def get_ads_for_review(
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         logger.info("读取人群包级别统计数据...")
         logger.info("读取人群包级别统计数据...")
         by_tier_stats = {}
         by_tier_stats = {}
+        by_tier_goal = {}
         try:
         try:
             # 读取 portfolio_summary JSON 文件
             # 读取 portfolio_summary JSON 文件
             portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
             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:
                 with open(portfolio_file, "r", encoding="utf-8") as f:
                     portfolio_data = json.load(f)
                     portfolio_data = json.load(f)
                 by_tier_stats = portfolio_data.get("by_audience_tier", {})
                 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:
             else:
                 logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file},将使用全局均值兜底")
                 logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file},将使用全局均值兜底")
-                # 可以选择在这里调用 calculate_portfolio_summary 生成文件
-                # 但为了简化,我们先用空字典兜底
+                by_tier_goal = {}
         except Exception as e:
         except Exception as e:
             logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
             logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
             by_tier_stats = {}
             by_tier_stats = {}
+            by_tier_goal = {}
 
 
         # 计算广告年龄
         # 计算广告年龄
         df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
         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_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
         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 = []      # 零消耗待关停
         zero_spend_ads = []      # 零消耗待关停
@@ -936,6 +372,8 @@ async def get_ads_for_review(
             bid_inc = bool(row.get("bid_increased_7d", False))
             bid_inc = bool(row.get("bid_increased_7d", False))
             creative_chg = bool(row.get("creative_changed_7d", False))
             creative_chg = bool(row.get("creative_changed_7d", False))
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
             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)
             bid_amount = float(row.get("bid_amount", 0) or 0)
 
 
             # 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
             # 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
@@ -959,28 +397,89 @@ async def get_ads_for_review(
                     })
                     })
                     continue
                     continue
 
 
+            # 昨日消耗(用于关停消耗门槛:投手经验2.4 "当天消耗>300")
+            yesterday_cost = float(row.get("yesterday_cost", 0) or 0)
+
             # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
             # 待优化评估: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 = (
             decay_signal = (
                 stable_days >= 7
                 stable_days >= 7
                 and cost_7d_avg < 100
                 and cost_7d_avg < 100
                 and (bid_inc or creative_chg)
                 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 = (
             bid_up_candidate = (
                 (not pd.isna(f_roi))
                 (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 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
             ) if BID_ADJUSTMENT_ENABLED else False
+
+            # 降价:ROI低于渠道均值10% + 裂变低于同类10% + 消耗≥500元/天
             bid_down_candidate = (
             bid_down_candidate = (
                 (not pd.isna(f_roi))
                 (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 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
             ) 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正常(基于决策树)
             # 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
             scale_up_candidate = (
             scale_up_candidate = (
                 ad_age is not None
                 ad_age is not None
@@ -991,6 +490,18 @@ async def get_ads_for_review(
                 and f_roi >= roi_mean * 0.9  # ROI正常(≥均值的90%)
                 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  # 标记是否被年龄保护排除
             age_protected_skip = False  # 标记是否被年龄保护排除
@@ -1056,6 +567,10 @@ async def get_ads_for_review(
                     "bid_amount": round(bid_amount, 2),
                     "bid_amount": round(bid_amount, 2),
                     "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
                     "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
                     "scale_up_candidate": scale_up_candidate,  # 新增:扩量候选标记
                     "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 =====
                 # ===== 新增:添加 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_mean"] = tier_stats.get("fission_mean")
                 ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
                 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参考)
                 # 计算动态阈值(供LLM参考)
                 tier_roi_p50 = tier_stats.get("roi_p50", roi_mean)  # 兜底用全局均值
                 tier_roi_p50 = tier_stats.get("roi_p50", roi_mean)  # 兜底用全局均值
 
 
@@ -1118,6 +644,23 @@ async def get_ads_for_review(
                     else:
                     else:
                         ad_dict["high_burn_alert"] = False
                         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)
                 need_review_ads.append(ad_dict)
                 continue
                 continue
 
 
@@ -1138,19 +681,20 @@ async def get_ads_for_review(
                 "p50": round(roi_p50, 4),
                 "p50": round(roi_p50, 4),
                 "p75": round(roi_p75, 4),
                 "p75": round(roi_p75, 4),
                 "p90": round(roi_p90, 4),
                 "p90": round(roi_p90, 4),
-                "cost_7d_avg_median": round(cost_median, 2),
             },
             },
             "bid_adjustment": {
             "bid_adjustment": {
                 "enabled": BID_ADJUSTMENT_ENABLED,
                 "enabled": BID_ADJUSTMENT_ENABLED,
                 "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
                 "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
                 "bid_up_line": round(roi_mean * params["BID_UP_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": {
             "thresholds_used": {
                 "ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
                 "ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
                 "BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
                 "BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
                 "BID_UP_ROI_FACTOR": params["BID_UP_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),
                 "roi_mean": round(roi_mean, 4),
                 "pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
                 "pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
                 "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_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 分布
         roi_series = df["动态ROI_7日均值"].dropna()
         roi_series = df["动态ROI_7日均值"].dropna()
         roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
         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_down_line = roi_mean * BID_DOWN_ROI_FACTOR
         bid_up_line = roi_mean * BID_UP_ROI_FACTOR
         bid_up_line = roi_mean * BID_UP_ROI_FACTOR
 
 
@@ -1563,10 +1105,11 @@ async def query_ad_detail(
         # 全局上下文
         # 全局上下文
         global_context = {
         global_context = {
             "全体动态ROI均值": round(roi_mean, 4),
             "全体动态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_down_line, 4),
             "ROI提价线": round(bid_up_line, 4),
             "ROI提价线": round(bid_up_line, 4),
-            "全体消耗中位数": round(cost_median, 2),
+            "提价消耗上限": BID_UP_MAX_SPEND,
+            "关停消耗门槛(昨日)": ROI_LOW_MIN_YESTERDAY_COST,
         }
         }
 
 
         result = {
         result = {

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

@@ -1,10 +1,11 @@
 """
 """
 安全护栏引擎 — auto_put_ad_mini
 安全护栏引擎 — auto_put_ad_mini
 
 
-6 道护栏按顺序执行:
+7 道护栏按顺序执行:
   1. ColdStartGuardrail    — 冷启动保护
   1. ColdStartGuardrail    — 冷启动保护
   2. DataFreshnessGuardrail — 数据新鲜度校验
   2. DataFreshnessGuardrail — 数据新鲜度校验
-  3. BidBoundaryGuardrail  — 出价边界钳位
+  3. BidBoundaryGuardrail  — 出价边界钳位(0.05~1.00元)
+  3.5 BidRangeGuardrail    — 调幅范围钳位(提价5-10%, 降价3-5%)
   4. RateLimitGuardrail    — 频率限制(每日次数/间隔/累计调幅)
   4. RateLimitGuardrail    — 频率限制(每日次数/间隔/累计调幅)
   5. DailyOpsCapGuardrail  — 每日操作总量上限
   5. DailyOpsCapGuardrail  — 每日操作总量上限
   6. DryRunGuardrail       — 干运行模式
   6. DryRunGuardrail       — 干运行模式
@@ -39,6 +40,10 @@ from config import (
     CAUTIOUS_DAYS,
     CAUTIOUS_DAYS,
     BID_FLOOR_YUAN,
     BID_FLOOR_YUAN,
     BID_CEILING_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,
     MAX_ADJUSTMENTS_PER_AD_PER_DAY,
     MIN_ADJUSTMENT_INTERVAL_HOURS,
     MIN_ADJUSTMENT_INTERVAL_HOURS,
     MAX_DAILY_CUMULATIVE_CHANGE_PCT,
     MAX_DAILY_CUMULATIVE_CHANGE_PCT,
@@ -119,8 +124,8 @@ class AdjustmentHistory:
         })
         })
         self._data[ad_key]["last_ts"] = now
         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"] = [
         self._data[ad_key]["adjustments"] = [
             a for a in self._data[ad_key]["adjustments"]
             a for a in self._data[ad_key]["adjustments"]
             if a.get("ts", "") >= cutoff
             if a.get("ts", "") >= cutoff
@@ -138,6 +143,19 @@ class AdjustmentHistory:
                 count += 1
                 count += 1
         return count
         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="")
         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: 频率限制
 # 护栏 4: 频率限制
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
@@ -458,6 +526,7 @@ def _run_guardrails(
         ColdStartGuardrail(),
         ColdStartGuardrail(),
         DataFreshnessGuardrail(),
         DataFreshnessGuardrail(),
         BidBoundaryGuardrail(),
         BidBoundaryGuardrail(),
+        BidRangeGuardrail(),
         RateLimitGuardrail(),
         RateLimitGuardrail(),
         DailyOpsCapGuardrail(),
         DailyOpsCapGuardrail(),
         DryRunGuardrail() if dry_run or DRY_RUN_MODE else None,
         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 pathlib import Path
 from typing import Any, Dict, List, Optional, Tuple
 from typing import Any, Dict, List, Optional, Tuple
 
 
+import numpy as np
 import pandas as pd
 import pandas as pd
 
 
 from agent.tools import tool
 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))),
                 "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_spend_days_30d >= 7 视为稳定
     stable_pct = 0.0
     stable_pct = 0.0
     if "stable_spend_days_30d" in df.columns and ad_count > 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_cost_avg": round(daily_cost_avg, 2),
         "daily_revenue_avg": round(daily_revenue_avg, 2),
         "daily_revenue_avg": round(daily_revenue_avg, 2),
         **roi_stats,
         **roi_stats,
-        **fission_stats,  # 新增
+        **fission_stats,
+        **ctr_stats,
+        **bid_stats,
         "stable_ads_pct": round(stable_pct, 4),
         "stable_ads_pct": round(stable_pct, 4),
     }
     }
 
 
@@ -387,6 +414,18 @@ async def calculate_portfolio_summary(
             for tier, group in df.groupby("audience_tier"):
             for tier, group in df.groupby("audience_tier"):
                 by_tier[str(tier)] = _describe_group(group)
                 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)
         global_stats = _describe_group(df)
 
 
@@ -401,6 +440,7 @@ async def calculate_portfolio_summary(
             "source_csv": str(csv_path),
             "source_csv": str(csv_path),
             "by_account": by_account,
             "by_account": by_account,
             "by_audience_tier": by_tier,
             "by_audience_tier": by_tier,
+            "by_tier_goal": by_tier_goal,
             "global": global_stats,
             "global": global_stats,
             "by_date": by_date,
             "by_date": by_date,
             "market_signal": market_signal,
             "market_signal": market_signal,

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

@@ -193,7 +193,7 @@ async def generate_report(
     生成决策报告。
     生成决策报告。
 
 
     Args:
     Args:
-        decision_csv: 决策结果 CSV 路径(analyze_ads 输出)
+        decision_csv: 决策结果 CSV 路径(apply_decisions 输出)
         end_date: 结束日期
         end_date: 结束日期
 
 
     Returns:
     Returns:
@@ -262,120 +262,3 @@ async def generate_report(
         return ToolResult(title="报告生成失败", output=str(e))
         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
         np.nan
     )
     )
 
 
-    # 合并回原 DataFrame(只保留昨日的 ROI)
+    # 同时保留昨日消耗(用于关停消耗门槛判断,投手经验2.4)
+    yesterday_df["yesterday_cost"] = yesterday_df["cost"]
+
+    # 合并回原 DataFrame(保留昨日的 ROI + 昨日消耗)
     ad_df = ad_df.merge(
     ad_df = ad_df.merge(
-        yesterday_df[["ad_id", "yesterday_roi"]],
+        yesterday_df[["ad_id", "yesterday_roi", "yesterday_cost"]],
         on="ad_id",
         on="ad_id",
         how="left"
         how="left"
     )
     )
@@ -436,10 +439,14 @@ async def calculate_roi_metrics(
         summary_30d = _calculate_30d_summary(ad_df)
         summary_30d = _calculate_30d_summary(ad_df)
 
 
         # Step 6: 合并所有指标(取最新一天的广告属性)
         # Step 6: 合并所有指标(取最新一天的广告属性)
-        latest_ad = ad_df[ad_df["date"] == end_date_str][[
+        latest_cols = [
             "ad_id", "account_id", "ad_name", "create_time",
             "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 = latest_ad.merge(summary_7d, on="ad_id", how="left")
         result_df = result_df.merge(summary_30d, 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日均值 的均值(供决策引擎做相对比较)
         # 计算全体 动态ROI_7日均值 的均值(供决策引擎做相对比较)
         f7_7d_mean_all = result_df["动态ROI_7日均值"].mean() if "动态ROI_7日均值" in result_df.columns else float("nan")
         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 = _MINI_DIR / "outputs"
         metrics_dir.mkdir(parents=True, exist_ok=True)
         metrics_dir.mkdir(parents=True, exist_ok=True)
         metrics_csv = metrics_dir / f"metrics_{end_date_str}.csv"
         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 (
 from examples.auto_put_ad_mini.tools.ad_decision import (
     _extract_audience_tier,
     _extract_audience_tier,
     _calculate_ad_age_days,
     _calculate_ad_age_days,
-    _run_decision_engine,
     _detect_decay_signals,
     _detect_decay_signals,
 )
 )
 from examples.auto_put_ad_mini.tools.report_generator import (
 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(f"   最终数据: {len(df_final)} 个广告, {len(df_final.columns)} 列")
 
 
-# ⑨ 运行决策引擎
-print("\n⑨ 运行三维度决策引擎...")
+# ⑨ 数据统计(规则引擎已废弃,仅输出指标统计)
+print("\n⑨ 数据统计...")
 f_roi_mean_all = df_final["动态ROI_7日均值"].mean()
 f_roi_mean_all = df_final["动态ROI_7日均值"].mean()
 print(f"   全体 动态ROI_7日均值 均值: {f_roi_mean_all:.4f}")
 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)
 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")
 df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
 print(f"   CSV: {csv_path}")
 print(f"   CSV: {csv_path}")
 
 
 # XLSX
 # 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)
 _write_xlsx_with_format(df_out, xlsx_path)
 print(f"   XLSX: {xlsx_path}")
 print(f"   XLSX: {xlsx_path}")
 
 
 print("\n=" * 60)
 print("\n=" * 60)
-print("验证完成!")
+print("验证完成!(注:规则引擎已废弃,决策请使用智能引擎 execute_once.py)")
 print("=" * 60)
 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()