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

feat(roi): prioritize three-day stops and fix daily score

刘立冬 3 дней назад
Родитель
Сommit
3133e85576

+ 3 - 3
AGENTS.md

@@ -41,10 +41,10 @@
 
 
 - ROI 是指导渠道、账户、广告和创意动作的核心北极星指标,必须作为独立领域模块维护,不能散落在飞书、腾讯 API 或调度代码中。
 - ROI 是指导渠道、账户、广告和创意动作的核心北极星指标,必须作为独立领域模块维护,不能散落在飞书、腾讯 API 或调度代码中。
 - ROI 指标计算必须保持纯数据输入/输出,不能依赖飞书审批、腾讯写操作或具体动作执行器。动作策略只能消费一个明确的 `metric_version`。
 - ROI 指标计算必须保持纯数据输入/输出,不能依赖飞书审批、腾讯写操作或具体动作执行器。动作策略只能消费一个明确的 `metric_version`。
-- 当前日级 ROI 指标版本为 `north_star_roi_t15_v8`,策略版本为 `roi_policy_v13`,报表版本为 `roi_report_v37`,使用已发布参数 `20260712_A0-A15_v2`。T0 裂变人数取 `SUM(t0_fission_uv_root)`,不得使用旧字段 `t0裂变人数`。首层效率收入取 `SUM(效率收入)`,T0 实际裂变收入取当天的 `SUM(裂变效率收入)`;只预测 T1-T15 增量:`T0实际裂变收入 * (传播裂变系数 - 1)`,预测总收入为 `首层效率收入 + T0实际裂变收入 + 预测T1-T15裂变收入`,禁止继续读取已废弃的 `多层t0裂变收入`。
+- 当前日级 ROI 指标版本为 `north_star_roi_t15_v8`,策略版本为 `roi_policy_v15`,报表版本为 `roi_report_v41`,使用已发布参数 `20260712_A0-A15_v2`。T0 裂变人数取 `SUM(t0_fission_uv_root)`,不得使用旧字段 `t0裂变人数`。首层效率收入取 `SUM(效率收入)`,T0 实际裂变收入取当天的 `SUM(裂变效率收入)`;只预测 T1-T15 增量:`T0实际裂变收入 * (传播裂变系数 - 1)`,预测总收入为 `首层效率收入 + T0实际裂变收入 + 预测T1-T15裂变收入`,禁止继续读取已废弃的 `多层t0裂变收入`。
 - 小程序传播裂变系数按 `人群包+转化目标精确 -> 人群包回退 -> 转化目标回退 -> 渠道回退` 匹配;公众号按 `合作方+公众号精确 -> 合作方回退 -> 渠道回退` 匹配。样本不足的精确实体不能使用自身系数。
 - 小程序传播裂变系数按 `人群包+转化目标精确 -> 人群包回退 -> 转化目标回退 -> 渠道回退` 匹配;公众号按 `合作方+公众号精确 -> 合作方回退 -> 渠道回退` 匹配。样本不足的精确实体不能使用自身系数。
 - 小程序日级 ODPS 数据必须保留 `广告优化目标`;缺失目标只能进入人群包回退,不得默认成关键页面访问。企微 ROI 口径未复核,优先使用已发布的合作方 SQL 计算结果,合作方未匹配、样本不足或旧版本无企微参数时默认参考系数为 2.5 并明确标注;企微不进入阈值样本池且不得生成调控动作。
 - 小程序日级 ODPS 数据必须保留 `广告优化目标`;缺失目标只能进入人群包回退,不得默认成关键页面访问。企微 ROI 口径未复核,优先使用已发布的合作方 SQL 计算结果,合作方未匹配、样本不足或旧版本无企微参数时默认参考系数为 2.5 并明确标注;企微不进入阈值样本池且不得生成调控动作。
-- ROI 报表包含小程序创意级、小程序广告级和公众号的三日汇总与每日明细共 6 个主 Sheet,其中广告级两个 Sheet 默认隐藏。金额、UV 和人数显示三日总量/3;ROI 和裂变率使用三日汇总后的加权口径。可见尾部列按“当日效率ROI -> 预测总效率ROI -> 关停线 -> 扩量线(P80) -> 建议动作 -> 建议说明 -> 当前创意状态”排列;当前创意状态只对创意级关停建议只读腾讯并显示正常、已停止或读取失败,其他行留空。审批选择、整体三日排名百分位、最新日UV、覆盖天数、昨日成本、关停规则、预算选择状态、实际关停成本占比、动作原因、阈值样本状态和执行结果只保留为隐藏审计字段;关停线分位点只进入运行摘要和数据库快照,不写入业务 Sheet。“当日效率ROI”和“预测总效率ROI”均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数。汇总顺序为关停、扩量、其他中间 ROI、观察,第一条扩量行顶部使用粗线分隔。每日明细的 `dt` 必须位于第一列并按日期倒序。主 Sheet 冻结首行和前 7 列。
+- ROI 报表包含小程序创意级、小程序广告级和公众号的三日汇总与每日明细共 6 个主 Sheet,其中广告级两个 Sheet 默认隐藏。金额、UV 和人数显示三日总量/3;ROI 和裂变率使用三日汇总后的加权口径。汇总表可见尾部列按“三日均值ROI -> 当日效率ROI -> 预测总效率ROI -> 关停线 -> 扩量线(P80) -> 建议动作 -> 建议说明 -> 当前创意状态”排列;三日均值ROI为三日加权实际ROI,当日效率ROI为最新日实际ROI,预测总效率ROI为三日加权预测ROI,不单列最新日预测ROI。当前创意状态只对创意级关停建议只读腾讯并显示正常、已停止或读取失败,其他行留空。审批选择、整体三日排名百分位、最新日UV、覆盖天数、昨日成本、关停规则、预算选择状态、实际关停成本占比、动作原因、阈值样本状态和执行结果只保留为隐藏审计字段;关停线分位点只进入运行摘要和数据库快照,不写入业务 Sheet。三列ROI均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数。汇总顺序为关停、扩量、其他中间 ROI、观察,第一条扩量行顶部使用粗线分隔。每日明细的 `dt` 必须位于第一列并按日期倒序。主 Sheet 冻结首行和前 7 列。
 - ROI 审批表应展示所有进入阈值样本池的小程序和公众号实体,以及最新日首层 UV>200 的补充观察实体;不能只展示有动作的数据。企微暂不进入本版报表、阈值和动作。正式样本按关停、扩量、其他中间 ROI、观察排列,补充观察置底并按最新日 UV 倒序。
 - ROI 审批表应展示所有进入阈值样本池的小程序和公众号实体,以及最新日首层 UV>200 的补充观察实体;不能只展示有动作的数据。企微暂不进入本版报表、阈值和动作。正式样本按关停、扩量、其他中间 ROI、观察排列,补充观察置底并按最新日 UV 倒序。
 - 成熟参数是独立版本化只读资产。审计后的参数必须通过更新脚本发布到 MySQL 的 `roi_fission_parameter_release` 和 `roi_fission_parameter_value`;日级 ROI 只从数据库消费 `ROI_FISSION_PARAMETER_VERSION` 指定且通过内容哈希校验的版本,不能在日常任务中现场重算或静默回退到镜像文件。离线复算只能生成草稿,经恒等式、行数、匹配率和新旧结果对账后才能发布。
 - 成熟参数是独立版本化只读资产。审计后的参数必须通过更新脚本发布到 MySQL 的 `roi_fission_parameter_release` 和 `roi_fission_parameter_value`;日级 ROI 只从数据库消费 `ROI_FISSION_PARAMETER_VERSION` 指定且通过内容哈希校验的版本,不能在日常任务中现场重算或静默回退到镜像文件。离线复算只能生成草稿,经恒等式、行数、匹配率和新旧结果对账后才能发布。
 - ROI 报表只上传一次,同一在线表链接发送到 ROI 通知群和 `FEISHU_OPERATOR_CHAT_ID` 投放审批群;群 ID 相同时必须去重。
 - ROI 报表只上传一次,同一在线表链接发送到 ROI 通知群和 `FEISHU_OPERATOR_CHAT_ID` 投放审批群;群 ID 相同时必须去重。
@@ -52,7 +52,7 @@
 - 策略阈值和动作语义使用独立 `policy_version`;指标版本与策略版本必须同时写入每个运行批次。
 - 策略阈值和动作语义使用独立 `policy_version`;指标版本与策略版本必须同时写入每个运行批次。
 - 每次运行必须保存完整实体快照、阈值配置、动作建议、可执行性原因和最终执行审计,不能只保存候选或飞书表格。
 - 每次运行必须保存完整实体快照、阈值配置、动作建议、可执行性原因和最终执行审计,不能只保存候选或飞书表格。
 - 日级 ROI 当前读取 T-1 至 T-3 的连续三日数据,并使用 `usersharedepth<=1`。连续三天每天首层 UV>200、成本>0 且 ROI 有效的小程序创意级和公众号实体分别进入渠道独立样本池。公众号即转使用自身合格实体等权 P20;小程序按 T-1 创意级实际成本5%软预算动态反推关停线和实体分位点,广告级重复聚合成本不得进入分母。
 - 日级 ROI 当前读取 T-1 至 T-3 的连续三日数据,并使用 `usersharedepth<=1`。连续三天每天首层 UV>200、成本>0 且 ROI 有效的小程序创意级和公众号实体分别进入渠道独立样本池。公众号即转使用自身合格实体等权 P20;小程序按 T-1 创意级实际成本5%软预算动态反推关停线和实体分位点,广告级重复聚合成本不得进入分母。
-- 小程序三日持续低 ROI、单日绝对低 ROI、单日高 UV 低分位按 6:2:2 获得目标成本的60%/20%/20%基础额度,各组按对应预测 ROI 从低到高取连续前缀,未用额度只在剩余候选间软流转。目标占昨日总成本5%,软上限5.5%,绝对上限6%。非三日正式样本中最新日 UV>200且预测 ROI≤0.20 进入绝对低 ROI 组;ROI>0.20且最新日 UV>500进入高 UV 动态分位组,不再使用固定 P30。高 ROI 仍使用合格小程序创意实体等权 P80。所有小程序关停要求广告 age>3;公众号只通知参考。
+- 小程序三日持续低 ROI 与单日补充关停按 6:4 获得目标成本的60%/40%基础额度,各组按对应预测 ROI 从低到高取连续前缀,未用额度只在剩余候选间软流转。目标占昨日总成本5%,软上限5.5%,绝对上限6%。三日正式判断优先:进入三日正式判断的小程序创意无论是否被预算选中或生成何种动作,都不得再进入单日判断。其余创意仅在最新日 UV>200、成本>0、ROI有效且广告 age>3天时进入单日候选池;最新日预测 ROI 必须同时不高于0.20且不高于候选池实体等权P10才成为单日关停候选。单日规则不使用历史保护。高 ROI 仍使用合格小程序创意实体等权 P80;公众号只通知参考。
 - 同广告永久基础出价 3 天内最多上调一次,且不超过首次纳管基础价的 2 倍。调整后必须同步实时 CPM 模块的基础出价,避免恢复旧值。
 - 同广告永久基础出价 3 天内最多上调一次,且不超过首次纳管基础价的 2 倍。调整后必须同步实时 CPM 模块的基础出价,避免恢复旧值。
 - ROI 逐行审批有效期默认 120 分钟。腾讯写操作必须与实时调控共用数据库 advisory lock,执行前回读映射和状态,执行后再次回读校验;执行结果必须回写表格并发送飞书通知,通知失败只能重试通知,不能重复腾讯写操作。
 - ROI 逐行审批有效期默认 120 分钟。腾讯写操作必须与实时调控共用数据库 advisory lock,执行前回读映射和状态,执行后再次回读校验;执行结果必须回写表格并发送飞书通知,通知失败只能重试通知,不能重复腾讯写操作。
 - `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=0` 只允许计算、快照、报表和审批预览;只有 `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=1`、`ROI_SHEET_APPROVAL_ENABLED=1` 时,表格批准后才允许自动执行腾讯写操作。
 - `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=0` 只允许计算、快照、报表和审批预览;只有 `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=1`、`ROI_SHEET_APPROVAL_ENABLED=1` 时,表格批准后才允许自动执行腾讯写操作。

+ 5 - 6
examples/auto_put_ad_mini/.env.example

@@ -33,9 +33,9 @@ DAILY_ROI_ENABLED=0
 DAILY_ROI_HOUR=9
 DAILY_ROI_HOUR=9
 DAILY_ROI_MINUTE=0
 DAILY_ROI_MINUTE=0
 DAILY_ROI_RUN_ON_STARTUP=0
 DAILY_ROI_RUN_ON_STARTUP=0
-# 每天14:30运行独立内部测试,只将“自动化投放”分表发送到“内部”Webhook,不进入腾讯审批执行。
+# 每天15:30运行独立内部测试,只将“自动化投放”分表发送到“内部”Webhook,不进入腾讯审批执行。
 ROI_INTERNAL_TEST_ENABLED=0
 ROI_INTERNAL_TEST_ENABLED=0
-ROI_INTERNAL_TEST_HOUR=14
+ROI_INTERNAL_TEST_HOUR=15
 ROI_INTERNAL_TEST_MINUTE=30
 ROI_INTERNAL_TEST_MINUTE=30
 # 生产默认去重;测试环境显式设0时每次触发使用独立测试批次。
 # 生产默认去重;测试环境显式设0时每次触发使用独立测试批次。
 ROI_INTERNAL_TEST_DEDUP_ENABLED=1
 ROI_INTERNAL_TEST_DEDUP_ENABLED=1
@@ -54,15 +54,14 @@ ROI_SELF_STOP_COST_TARGET_RATIO=0.05
 ROI_SELF_STOP_COST_SOFT_UPPER_RATIO=0.055
 ROI_SELF_STOP_COST_SOFT_UPPER_RATIO=0.055
 ROI_SELF_STOP_COST_HARD_CAP_RATIO=0.06
 ROI_SELF_STOP_COST_HARD_CAP_RATIO=0.06
 ROI_SELF_STOP_THREE_DAY_SHARE=0.60
 ROI_SELF_STOP_THREE_DAY_SHARE=0.60
-ROI_SELF_STOP_ONE_DAY_HARD_SHARE=0.20
-ROI_SELF_STOP_ONE_DAY_HIGH_UV_SHARE=0.20
+ROI_SELF_STOP_ONE_DAY_SHARE=0.40
 ROI_PARTNER_STOP_QUANTILE=0.20
 ROI_PARTNER_STOP_QUANTILE=0.20
 ROI_UP_QUANTILE=0.80
 ROI_UP_QUANTILE=0.80
 ROI_OBSERVE_MIN_LATEST_UV=200
 ROI_OBSERVE_MIN_LATEST_UV=200
-# 非三日正式样本:UV>200且ROI<=0.20进入绝对低ROI组;ROI>0.20且UV>500进入高UV动态分位组
+# 严格排除三日正式样本;剩余创意需同时满足UV>200、ROI<=0.20和实体等权P10
 ROI_ONE_DAY_MIN_UV=200
 ROI_ONE_DAY_MIN_UV=200
-ROI_ONE_DAY_HIGH_UV_MIN_UV=500
 ROI_ONE_DAY_HARD_STOP_ROI=0.20
 ROI_ONE_DAY_HARD_STOP_ROI=0.20
+ROI_ONE_DAY_STOP_QUANTILE=0.10
 ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2
 ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2
 # ROI报表发送到此群;同时抄送 FEISHU_OPERATOR_CHAT_ID,重复ID自动去重。
 # ROI报表发送到此群;同时抄送 FEISHU_OPERATOR_CHAT_ID,重复ID自动去重。
 # 审批表链接为获得链接者可编辑,黄色审批列选择“批准”即自动执行。
 # 审批表链接为获得链接者可编辑,黄色审批列选择“批准”即自动执行。

+ 5 - 5
examples/auto_put_ad_mini/DEPLOYMENT.md

@@ -175,16 +175,16 @@ curl -X POST http://localhost:8080/trigger | jq .
 - 正式阈值样本为连续三天每天首层 UV>200、成本>0 且 ROI 有效的小程序创意级和公众号实体;两类渠道独立计算,禁止合并分位点。公众号即转使用自身合格实体等权 P20;小程序按 T-1 实际成本软预算动态反推关停线和实体分位点。
 - 正式阈值样本为连续三天每天首层 UV>200、成本>0 且 ROI 有效的小程序创意级和公众号实体;两类渠道独立计算,禁止合并分位点。公众号即转使用自身合格实体等权 P20;小程序按 T-1 实际成本软预算动态反推关停线和实体分位点。
 - 小程序广告级从 ODPS 原始数据按广告直接 `COUNT(DISTINCT mid)`,复用小程序三日动态关停线,但不重复进入创意成本预算样本池;低于关停线且广告 age>3 天时生成广告级关停建议。
 - 小程序广告级从 ODPS 原始数据按广告直接 `COUNT(DISTINCT mid)`,复用小程序三日动态关停线,但不重复进入创意成本预算样本池;低于关停线且广告 age>3 天时生成广告级关停建议。
 - 金额、UV 和人数在三日汇总表展示为三日总量/3;ROI 与裂变率使用三日总分子/总分母的加权口径。
 - 金额、UV 和人数在三日汇总表展示为三日总量/3;ROI 与裂变率使用三日总分子/总分母的加权口径。
-- 小程序创意关停以小程序创意级 T-1 实际成本为唯一成本分母,目标占比 5%、软上限 5.5%、绝对上限 6%。三日持续低 ROI、单日绝对低 ROI、单日高 UV 低分位按 6:2:2 获得 3%/1%/1%基础成本额度;各组按对应预测总效率 ROI 从低到高取连续前缀,未用额度只在仍满足候选条件的组间软流转,不为凑额度跳过更差创意或关停非候选。
-- 未进入三日正式样本的小程序创意增加单日补充判断:最新日首层 UV>200 且预测总效率 ROI≤0.20 进入单日绝对低 ROI 组;ROI>0.20 且最新日首层 UV>500 进入单日高 UV 组。两组不再使用固定 P30,均按 T-1 实际成本额度动态反推关停线和实体分位点。命中候选但未被预算选中、或广告 age≤3 天时只观察。动作原因同时展示最新日判断值、昨日成本与仅供参考的三日预测 ROI
+- 小程序创意关停以小程序创意级 T-1 实际成本为唯一成本分母,目标占比 5%、软上限 5.5%、绝对上限 6%。三日持续低 ROI 与单日补充规则按 6:4 获得 3%/2%基础成本额度;两组按对应预测总效率 ROI 从低到高取连续前缀,未用额度只在仍满足候选条件的组间软流转,不为凑额度跳过更差创意或关停非候选。
+- 三日正式判断拥有严格优先级:只要小程序创意满足连续三日正式样本条件,无论是否被成本预算选中或最终建议是什么,都不再进入单日规则。其余创意只有在最新日首层 UV>200、成本>0、ROI有效且广告 age>3天时进入单日候选池;最新日预测总效率 ROI 必须同时≤0.20且≤候选池实体等权P10才成为单日关停候选。命中候选但未被预算选中时只观察;不再使用历史保护
 - 报表包含小程序创意级、小程序广告级、公众号的三日汇总与每日明细共 6 个主 Sheet;广告级两个 Sheet 默认隐藏,企微暂不进入本版计算和报表。
 - 报表包含小程序创意级、小程序广告级、公众号的三日汇总与每日明细共 6 个主 Sheet;广告级两个 Sheet 默认隐藏,企微暂不进入本版计算和报表。
 - 每日明细 Sheet 的 `dt` 位于第一列,整表按日期倒序排列。
 - 每日明细 Sheet 的 `dt` 位于第一列,整表按日期倒序排列。
-- 可见 ROI 列简化为“当日效率ROI”和“预测总效率ROI”;其后依次展示动态“关停线”和“扩量线(P80)”公众号不参与扩量故扩量线留空;整体三日排名百分位保留为隐藏审计字段,关停线分位点只进入运行摘要和数据库快照,不写入业务 Sheet。
+- 三日汇总可见 ROI 列为“三日均值ROI”“当日效率ROI”和“预测总效率ROI”:分别表示三日加权实际ROI、最新日实际ROI和三日加权预测ROI,不单列最新日预测ROI;其后依次展示动态“关停线”和“扩量线(P80)”公众号不参与扩量故扩量线留空;整体三日排名百分位保留为隐藏审计字段,关停线分位点只进入运行摘要和数据库快照,不写入业务 Sheet。
 - “动作”可见列改为“建议动作”,其后展示“建议说明”;小程序创意级和广告级关停分别显示“关停创意”“关停广告”,但底层动作不变。创意关停按三日持续低 ROI、单日绝对低 ROI、单日高 UV 低分位三类原因分组,同类按对应 ROI 升序。底层无动作的正式中间区间在报表中显示为“观察”,但不生成可执行动作。昨日成本、关停规则、预算选择状态、实际关停成本占比、阈值样本状态和执行结果保留为隐藏审计字段。
 - “动作”可见列改为“建议动作”,其后展示“建议说明”;小程序创意级和广告级关停分别显示“关停创意”“关停广告”,但底层动作不变。创意关停按三日持续低 ROI、单日绝对低 ROI、单日高 UV 低分位三类原因分组,同类按对应 ROI 升序。底层无动作的正式中间区间在报表中显示为“观察”,但不生成可执行动作。昨日成本、关停规则、预算选择状态、实际关停成本占比、阈值样本状态和执行结果保留为隐藏审计字段。
 - 当前三日策略以小程序 T-1 成本预算动态线生成低 ROI 关停建议,公众号使用独立实体等权 P20;高 ROI 仍使用合格小程序创意实体等权 P80。所有小程序创意级和广告级关停均要求广告 age>3 天;创意级关停批准后只暂停对应动态创意,广告级关停批准后暂停整个广告。
 - 当前三日策略以小程序 T-1 成本预算动态线生成低 ROI 关停建议,公众号使用独立实体等权 P20;高 ROI 仍使用合格小程序创意实体等权 P80。所有小程序创意级和广告级关停均要求广告 age>3 天;创意级关停批准后只暂停对应动态创意,广告级关停批准后暂停整个广告。
-- 汇总表将三日总 T0 裂变人数 / 三日总首层 UV 的加权比例展示为“日均T0裂变率”。汇总顺序为关停、扩量、中间观察、条件不足观察;第一条扩量行和扩量后第一条观察行顶部均使用粗线分隔。“当日效率ROI”和“预测总效率ROI”均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数;两个“裂变系数-总裂变UV/…”比率列固定显示两位小数。“传播裂变系数匹配”保留审计数据但默认隐藏。
+- 汇总表将三日总 T0 裂变人数 / 三日总首层 UV 的加权比例展示为“日均T0裂变率”。汇总顺序为关停、扩量、中间观察、条件不足观察;第一条扩量行和扩量后第一条观察行顶部均使用粗线分隔。“三日均值ROI”“当日效率ROI”和“预测总效率ROI”均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数;两个“裂变系数-总裂变UV/…”比率列固定显示两位小数。“传播裂变系数匹配”保留审计数据但默认隐藏。
 - “审批选择”默认隐藏,需取消隐藏后审批;创意级“当前创意状态”只对关停建议只读腾讯状态,显示正常、已停止或读取失败,其他行留空。
 - “审批选择”默认隐藏,需取消隐藏后审批;创意级“当前创意状态”只对关停建议只读腾讯状态,显示正常、已停止或读取失败,其他行留空。
-- 完整主报表保存后,流程会额外按“小程序投流”渠道的“代理名称”生成一代理一份调控建议工作簿;文件名为 `YYYYMMDD_代理名称_调控建议.xlsx`。代理版只包含小程序创意级和广告级三日汇总,广告级 Sheet 继续隐藏,不包含每日明细。包名、广告age、日均首层UV、建议说明、收入、预测总效率ROI、P20/P80/P30、排名、两个裂变系数、日均T0裂变人数/率、审批、执行状态和幂等键均不会写入代理文件;内部“当日效率ROI”仅改名为两位小数的“评分”展示,建议动作仍按预测总效率ROI计算并保留“关停创意/关停广告/扩量/观察”。代理表默认只落本地;开启 `ROI_AGENCY_WEBHOOK_ENABLED` 后,飞书应用先上传在线表,再由 `ROI_AGENCY_WEBHOOKS_JSON` 中精确匹配的代理机器人发送卡片。未配置代理直接跳过,不回退总群;完整 webhook 只能放在真实 `.env` 或密钥系统,数据库和日志只保存哈希指纹。
+- 完整主报表保存后,流程会额外按“小程序投流”渠道的“代理名称”生成一代理一份调控建议工作簿;文件名为 `YYYYMMDD_代理名称_调控建议.xlsx`。代理版只包含小程序创意级和广告级三日汇总,广告级 Sheet 继续隐藏,不包含每日明细。包名、广告age、日均首层UV、建议说明、收入、预测总效率ROI、P20/P80/P30、排名、两个裂变系数、日均T0裂变人数/率、审批、执行状态和幂等键均不会写入代理文件;“评分”使用最新日实际ROI并保留两位小数,建议动作仍按内部策略生成并保留“关停创意/关停广告/扩量/观察”。代理表默认只落本地;开启 `ROI_AGENCY_WEBHOOK_ENABLED` 后,飞书应用先上传在线表,再由 `ROI_AGENCY_WEBHOOKS_JSON` 中精确匹配的代理机器人发送卡片。未配置代理直接跳过,不回退总群;完整 webhook 只能放在真实 `.env` 或密钥系统,数据库和日志只保存哈希指纹。
 - 可选内部测试任务每天北京时间15:30执行独立复算,只将“自动化投放”分表发送到“内部”Webhook,不发送完整主表、正式总群或代理群,也不生成可审批腾讯动作。缺少该分表时任务失败并告警。正式任务始终去重;内部测试使用 `ROI_INTERNAL_TEST_DEDUP_ENABLED` 控制,设为`0`时每次显式触发都发送新的测试批次。内部任务无startup入口,重启服务不会自动补发。
 - 可选内部测试任务每天北京时间15:30执行独立复算,只将“自动化投放”分表发送到“内部”Webhook,不发送完整主表、正式总群或代理群,也不生成可审批腾讯动作。缺少该分表时任务失败并告警。正式任务始终去重;内部测试使用 `ROI_INTERNAL_TEST_DEDUP_ENABLED` 控制,设为`0`时每次显式触发都发送新的测试批次。内部任务无startup入口,重启服务不会自动补发。
 - 09:00正式任务或15:30内部测试执行失败时,调度服务通过飞书应用向 `ROI_FAILURE_FEISHU_CHAT_ID` 发送红色告警卡片;未配置时回退 `FEISHU_OPERATOR_CHAT_ID`。告警失败只记日志,不会掩盖原任务失败。
 - 09:00正式任务或15:30内部测试执行失败时,调度服务通过飞书应用向 `ROI_FAILURE_FEISHU_CHAT_ID` 发送红色告警卡片;未配置时回退 `FEISHU_OPERATOR_CHAT_ID`。告警失败只记日志,不会掩盖原任务失败。
 
 

+ 8 - 9
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -90,11 +90,10 @@ ROI_SELF_STOP_COST_TARGET_RATIO=0.05
 ROI_SELF_STOP_COST_SOFT_UPPER_RATIO=0.055
 ROI_SELF_STOP_COST_SOFT_UPPER_RATIO=0.055
 ROI_SELF_STOP_COST_HARD_CAP_RATIO=0.06
 ROI_SELF_STOP_COST_HARD_CAP_RATIO=0.06
 ROI_SELF_STOP_THREE_DAY_SHARE=0.60
 ROI_SELF_STOP_THREE_DAY_SHARE=0.60
-ROI_SELF_STOP_ONE_DAY_HARD_SHARE=0.20
-ROI_SELF_STOP_ONE_DAY_HIGH_UV_SHARE=0.20
+ROI_SELF_STOP_ONE_DAY_SHARE=0.40
 ROI_ONE_DAY_MIN_UV=200
 ROI_ONE_DAY_MIN_UV=200
-ROI_ONE_DAY_HIGH_UV_MIN_UV=500
 ROI_ONE_DAY_HARD_STOP_ROI=0.20
 ROI_ONE_DAY_HARD_STOP_ROI=0.20
+ROI_ONE_DAY_STOP_QUANTILE=0.10
 ROI_PARTNER_STOP_QUANTILE=0.20
 ROI_PARTNER_STOP_QUANTILE=0.20
 ROI_UP_QUANTILE=0.80
 ROI_UP_QUANTILE=0.80
 ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2
 ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2
@@ -184,7 +183,7 @@ docker compose --env-file /dev/null run --rm \
 
 
 日级 ROI 单独分两阶段启用:
 日级 ROI 单独分两阶段启用:
 
 
-当前 `north_star_roi_t15_v8` 使用版本化传播裂变参数,策略版本为 `roi_policy_v13`,报表版本为 `roi_report_v37`。T0 裂变人数取 `SUM(t0_fission_uv_root)`,不再读取旧字段 `t0裂变人数`。小程序按人群包和转化目标、
+当前 `north_star_roi_t15_v8` 使用版本化传播裂变参数,策略版本为 `roi_policy_v15`,报表版本为 `roi_report_v41`。T0 裂变人数取 `SUM(t0_fission_uv_root)`,不再读取旧字段 `t0裂变人数`。小程序按人群包和转化目标、
 公众号按合作方和公众号匹配传播裂变系数;企微暂不进入本版报表、阈值和调控。首层效率收入读取 `效率收入`,T0 实际裂变收入读取当天的 `裂变效率收入`,
 公众号按合作方和公众号匹配传播裂变系数;企微暂不进入本版报表、阈值和调控。首层效率收入读取 `效率收入`,T0 实际裂变收入读取当天的 `裂变效率收入`,
 并以 T0 实际裂变收入乘传播裂变系数预测完整裂变收入。日级任务不会现场重算 cohort 参数。部署前应保持
 并以 T0 实际裂变收入乘传播裂变系数预测完整裂变收入。日级任务不会现场重算 cohort 参数。部署前应保持
 `ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2`。参数必须先发布到 MySQL,
 `ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2`。参数必须先发布到 MySQL,
@@ -198,14 +197,14 @@ ROI 表格使用获得链接者可编辑权限。默认隐藏的黄色【审批
 金额、UV 和人数展示三日总量/3,ROI 和裂变率使用三日汇总后的加权口径。连续三天每天
 金额、UV 和人数展示三日总量/3,ROI 和裂变率使用三日汇总后的加权口径。连续三天每天
 首层 UV>200、成本>0 且 ROI 有效的小程序创意和公众号实体分别建立渠道独立样本池。公众号即转使用自身合格实体等权 P20;小程序使用 T-1 创意级实际成本5%软预算动态反推关停线和实体分位点,禁止把广告级重复成本加入分母。
 首层 UV>200、成本>0 且 ROI 有效的小程序创意和公众号实体分别建立渠道独立样本池。公众号即转使用自身合格实体等权 P20;小程序使用 T-1 创意级实际成本5%软预算动态反推关停线和实体分位点,禁止把广告级重复成本加入分母。
 广告级直接从 ODPS 原始数据按广告去重,复用小程序三日动态关停线但不进入创意成本预算样本池;低于关停线且广告 age>3 天时生成广告级关停建议,批准后暂停整个广告。
 广告级直接从 ODPS 原始数据按广告去重,复用小程序三日动态关停线但不进入创意成本预算样本池;低于关停线且广告 age>3 天时生成广告级关停建议,批准后暂停整个广告。
-小程序三日持续低 ROI、单日绝对低 ROI、单日高 UV 低分位按 6:2:2 获得目标成本的60%/20%/20%基础额度。三组分别按三日加权预测 ROI、T-1预测 ROI、T-1预测 ROI 从低到高取连续前缀;目标关停成本占昨日总成本5%,软上限5.5%,绝对上限6%,未用额度只在剩余候选间软流转。
-未进入三日正式样本的小程序创意中,最新日首层 UV>200 且预测总效率 ROI≤0.20 进入绝对低 ROI 组;ROI>0.20 且最新日首层 UV>500 进入高 UV 动态分位组,不再使用固定 P30。命中候选但未被预算选中或 age≤3 天时观察。动作原因展示最新日预测 ROI、T-1实际成本与仅供参考的三日预测 ROI
-可见尾部列按“当日效率ROI → 预测总效率ROI → 关停线 → 扩量线(P80) → 建议动作 → 建议说明”排列;小程序创意级、广告级可见关停动作分别显示为“关停创意”“关停广告”,底层仍保存统一动作值供执行器消费。创意关停按三日持续低 ROI、单日绝对低 ROI、单日高 UV 低分位三类建议说明分组排序,同类内部按对应 ROI 升序。公众号不参与扩量,扩量线留空。
+小程序三日持续低 ROI 与单日补充规则按 6:4 获得目标成本的60%/40%基础额度。两组分别按三日加权预测 ROI、T-1预测 ROI 从低到高取连续前缀;目标关停成本占昨日总成本5%,软上限5.5%,绝对上限6%,未用额度只在剩余候选间软流转。
+三日正式判断严格优先:已满足连续三日正式样本条件的小程序创意,无论预算选择或最终动作如何,都不得进入单日判断。其余创意最新日首层 UV>200、成本>0、ROI有效且广告 age>3天时进入单日候选池,预测总效率 ROI 同时≤0.20和候选池实体等权P10才成为关停候选;不再使用历史保护
+三日汇总可见尾部列按“三日均值ROI → 当日效率ROI → 预测总效率ROI → 关停线 → 扩量线(P80) → 建议动作 → 建议说明”排列;三列分别是三日加权实际ROI、最新日实际ROI和三日加权预测ROI,不单列最新日预测ROI。小程序创意级、广告级可见关停动作分别显示为“关停创意”“关停广告”,底层仍保存统一动作值供执行器消费。创意关停按三日持续低 ROI、单日绝对低 ROI 且P10两类建议说明分组排序,同类内部按对应 ROI 升序。公众号不参与扩量,扩量线留空。
 整体三日排名百分位、是否低于三日关停线、最新日UV、覆盖天数、昨日成本、关停规则、预算选择状态、实际关停成本占比、阈值样本状态和执行结果只保留为隐藏审计字段;底层动作原因通过“建议说明”展示。
 整体三日排名百分位、是否低于三日关停线、最新日UV、覆盖天数、昨日成本、关停规则、预算选择状态、实际关停成本占比、阈值样本状态和执行结果只保留为隐藏审计字段;底层动作原因通过“建议说明”展示。
 每日明细 Sheet 的 `dt` 位于第一列并按日期倒序。主 Sheet 冻结首行和前 7 列,
 每日明细 Sheet 的 `dt` 位于第一列并按日期倒序。主 Sheet 冻结首行和前 7 列,
-关停线列为动态“关停线”;“关停线分位点”只进入运行摘要和数据库快照,不写入业务 Sheet。所有小程序创意级和广告级关停统一要求广告 age>3 天。汇总表将三日总 T0 裂变人数 / 三日总首层 UV 的加权比例展示为“日均T0裂变率”。合格小程序创意实体等权 P80 为扩量线,处于头部20%且广告 age≥3 时生成扩量建议。汇总顺序为关停、扩量、中间观察、条件不足观察;第一条扩量行和扩量后第一条观察行顶部均使用粗线分隔。“当日效率ROI”和“预测总效率ROI”均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数;两个“裂变系数-总裂变UV/…”比率列固定显示两位小数。“传播裂变系数匹配”保留审计数据但默认隐藏。
+关停线列为动态“关停线”;“关停线分位点”只进入运行摘要和数据库快照,不写入业务 Sheet。所有小程序创意级和广告级关停统一要求广告 age>3 天。汇总表将三日总 T0 裂变人数 / 三日总首层 UV 的加权比例展示为“日均T0裂变率”。合格小程序创意实体等权 P80 为扩量线,处于头部20%且广告 age≥3 时生成扩量建议。汇总顺序为关停、扩量、中间观察、条件不足观察;第一条扩量行和扩量后第一条观察行顶部均使用粗线分隔。三列ROI均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数;两个“裂变系数-总裂变UV/…”比率列固定显示两位小数。“传播裂变系数匹配”保留审计数据但默认隐藏。
 创意级“当前创意状态”只对关停建议只读腾讯状态并显示正常、已停止或读取失败,其他行留空;“审批选择”默认隐藏。
 创意级“当前创意状态”只对关停建议只读腾讯状态并显示正常、已停止或读取失败,其他行留空;“审批选择”默认隐藏。
-完整主报表写入成功后,服务额外按“小程序投流”渠道的“代理名称”生成一代理一份 `roi_agency_advice_v7` 工作簿,文件名为 `YYYYMMDD_代理名称_调控建议.xlsx`。代理版可见 Sheet 名为“小程序创意调控建议”,并保留默认隐藏的“小程序广告级三日汇总”,不包含每日明细;包名、广告age、日均首层UV、建议说明、收入、预测总效率ROI、阈值、排名、两个裂变系数、日均T0裂变人数/率、审批和执行审计列均物理删除。内部“当日效率ROI”仅以两位小数的“评分”列对外展示,不参与代理动作计算;建议动作仍按预测总效率ROI生成,并明确显示“关停创意”或“关停广告”。代理表生成不改变主报表、数据库快照或主审批链接。
+完整主报表写入成功后,服务额外按“小程序投流”渠道的“代理名称”生成一代理一份 `roi_agency_advice_v9` 工作簿,文件名为 `YYYYMMDD_代理名称_调控建议.xlsx`。代理版可见 Sheet 名为“小程序创意调控建议”,并保留默认隐藏的“小程序广告级三日汇总”,不包含每日明细;包名、广告age、日均首层UV、建议说明、收入、预测总效率ROI、阈值、排名、两个裂变系数、日均T0裂变人数/率、审批和执行审计列均物理删除。“评分”使用最新日实际ROI并保留两位小数,不参与代理动作计算;建议动作仍按内部策略生成,并明确显示“关停创意”或“关停广告”。代理表生成不改变主报表、数据库快照或主审批链接。
 
 
 正式任务每天北京时间09:00执行。任务固定以T-1为结束日,并在创建MySQL运行批次、生成报表和发送飞书前校验 `loghubods.opengid_base_data` 的精确T-1分区;小程序投流或公众号投流相关数据任一为空即失败关闭,禁止静默回退到更早分区。
 正式任务每天北京时间09:00执行。任务固定以T-1为结束日,并在创建MySQL运行批次、生成报表和发送飞书前校验 `loghubods.opengid_base_data` 的精确T-1分区;小程序投流或公众号投流相关数据任一为空即失败关闭,禁止静默回退到更早分区。
 
 

+ 10 - 21
examples/auto_put_ad_mini/roi_control/config.py

@@ -91,15 +91,14 @@ class RoiConfig:
     partner_min_daily_uv: float = 200
     partner_min_daily_uv: float = 200
     observe_min_latest_uv: float = 200
     observe_min_latest_uv: float = 200
     one_day_min_uv: float = 200
     one_day_min_uv: float = 200
-    one_day_high_uv_min_uv: float = 500
     one_day_hard_stop_roi: float = 0.20
     one_day_hard_stop_roi: float = 0.20
+    one_day_stop_quantile: float = 0.10
     self_stop_cost_soft_lower_ratio: float = 0.045
     self_stop_cost_soft_lower_ratio: float = 0.045
     self_stop_cost_target_ratio: float = 0.05
     self_stop_cost_target_ratio: float = 0.05
     self_stop_cost_soft_upper_ratio: float = 0.055
     self_stop_cost_soft_upper_ratio: float = 0.055
     self_stop_cost_hard_cap_ratio: float = 0.06
     self_stop_cost_hard_cap_ratio: float = 0.06
     self_stop_three_day_share: float = 0.60
     self_stop_three_day_share: float = 0.60
-    self_stop_one_day_hard_share: float = 0.20
-    self_stop_one_day_high_uv_share: float = 0.20
+    self_stop_one_day_share: float = 0.40
     stop_quantile: float = 0.20
     stop_quantile: float = 0.20
     up_quantile: float = 0.80
     up_quantile: float = 0.80
     stop_weight_cap_quantile: float = 0.95
     stop_weight_cap_quantile: float = 0.95
@@ -146,15 +145,12 @@ class RoiConfig:
                 os.getenv("ROI_OBSERVE_MIN_LATEST_UV", "200")
                 os.getenv("ROI_OBSERVE_MIN_LATEST_UV", "200")
             ),
             ),
             one_day_min_uv=float(os.getenv("ROI_ONE_DAY_MIN_UV", "200")),
             one_day_min_uv=float(os.getenv("ROI_ONE_DAY_MIN_UV", "200")),
-            one_day_high_uv_min_uv=float(
-                os.getenv(
-                    "ROI_ONE_DAY_HIGH_UV_MIN_UV",
-                    os.getenv("ROI_ONE_DAY_P30_MIN_UV", "500"),
-                )
-            ),
             one_day_hard_stop_roi=float(
             one_day_hard_stop_roi=float(
                 os.getenv("ROI_ONE_DAY_HARD_STOP_ROI", "0.20")
                 os.getenv("ROI_ONE_DAY_HARD_STOP_ROI", "0.20")
             ),
             ),
+            one_day_stop_quantile=float(
+                os.getenv("ROI_ONE_DAY_STOP_QUANTILE", "0.10")
+            ),
             self_stop_cost_soft_lower_ratio=float(
             self_stop_cost_soft_lower_ratio=float(
                 os.getenv("ROI_SELF_STOP_COST_SOFT_LOWER_RATIO", "0.045")
                 os.getenv("ROI_SELF_STOP_COST_SOFT_LOWER_RATIO", "0.045")
             ),
             ),
@@ -170,11 +166,8 @@ class RoiConfig:
             self_stop_three_day_share=float(
             self_stop_three_day_share=float(
                 os.getenv("ROI_SELF_STOP_THREE_DAY_SHARE", "0.60")
                 os.getenv("ROI_SELF_STOP_THREE_DAY_SHARE", "0.60")
             ),
             ),
-            self_stop_one_day_hard_share=float(
-                os.getenv("ROI_SELF_STOP_ONE_DAY_HARD_SHARE", "0.20")
-            ),
-            self_stop_one_day_high_uv_share=float(
-                os.getenv("ROI_SELF_STOP_ONE_DAY_HIGH_UV_SHARE", "0.20")
+            self_stop_one_day_share=float(
+                os.getenv("ROI_SELF_STOP_ONE_DAY_SHARE", "0.40")
             ),
             ),
             stop_quantile=float(
             stop_quantile=float(
                 os.getenv(
                 os.getenv(
@@ -218,15 +211,12 @@ class RoiConfig:
             self.partner_min_daily_uv,
             self.partner_min_daily_uv,
             self.observe_min_latest_uv,
             self.observe_min_latest_uv,
             self.one_day_min_uv,
             self.one_day_min_uv,
-            self.one_day_high_uv_min_uv,
         ) <= 0:
         ) <= 0:
             raise ValueError("ROI UV thresholds must be positive")
             raise ValueError("ROI UV thresholds must be positive")
-        if self.one_day_high_uv_min_uv <= self.one_day_min_uv:
-            raise ValueError(
-                "ROI_ONE_DAY_HIGH_UV_MIN_UV must be greater than ROI_ONE_DAY_MIN_UV"
-            )
         if not math.isfinite(self.one_day_hard_stop_roi):
         if not math.isfinite(self.one_day_hard_stop_roi):
             raise ValueError("ROI_ONE_DAY_HARD_STOP_ROI must be finite")
             raise ValueError("ROI_ONE_DAY_HARD_STOP_ROI must be finite")
+        if not 0 < self.one_day_stop_quantile < 1:
+            raise ValueError("ROI_ONE_DAY_STOP_QUANTILE must satisfy 0 < value < 1")
         if not (
         if not (
             0 <= self.self_stop_cost_soft_lower_ratio
             0 <= self.self_stop_cost_soft_lower_ratio
             <= self.self_stop_cost_target_ratio
             <= self.self_stop_cost_target_ratio
@@ -240,8 +230,7 @@ class RoiConfig:
             )
             )
         shares = (
         shares = (
             self.self_stop_three_day_share,
             self.self_stop_three_day_share,
-            self.self_stop_one_day_hard_share,
-            self.self_stop_one_day_high_uv_share,
+            self.self_stop_one_day_share,
         )
         )
         if min(shares) < 0 or not math.isclose(sum(shares), 1.0, abs_tol=1e-9):
         if min(shares) < 0 or not math.isclose(sum(shares), 1.0, abs_tol=1e-9):
             raise ValueError("ROI self stop allocation shares must be non-negative and sum to 1")
             raise ValueError("ROI self stop allocation shares must be non-negative and sum to 1")

+ 5 - 0
examples/auto_put_ad_mini/roi_control/metrics.py

@@ -309,6 +309,11 @@ def _summarize_entity(
             if np.isfinite(daily_predicted_roi[-1])
             if np.isfinite(daily_predicted_roi[-1])
             else np.nan
             else np.nan
         )
         )
+        record["最新日实际ROI"] = (
+            float(daily_actual_roi[-1])
+            if np.isfinite(daily_actual_roi[-1])
+            else np.nan
+        )
         for index, dt in enumerate(expected_dates):
         for index, dt in enumerate(expected_dates):
             actual_roi = (
             actual_roi = (
                 float(daily_actual_roi[index])
                 float(daily_actual_roi[index])

+ 3 - 0
examples/auto_put_ad_mini/roi_control/policy.py

@@ -33,6 +33,9 @@ POLICY_AUDIT_NUMBER_FIELDS = (
     "关停线分位点",
     "关停线分位点",
     "整体三日ROI排名百分位",
     "整体三日ROI排名百分位",
     "创意三日ROI排名百分位",
     "创意三日ROI排名百分位",
+    "单日ROI排名百分位",
+    "单日P10线",
+    "单日合并资格线",
 )
 )
 
 
 
 

+ 22 - 33
examples/auto_put_ad_mini/roi_control/reporting.py

@@ -28,9 +28,9 @@ OBSERVE_FILL = PatternFill("solid", fgColor="FFF2CC")
 APPROVAL_FILL = PatternFill("solid", fgColor="FFD966")
 APPROVAL_FILL = PatternFill("solid", fgColor="FFD966")
 APPROVAL_HEADER_FILL = PatternFill("solid", fgColor="BF9000")
 APPROVAL_HEADER_FILL = PatternFill("solid", fgColor="BF9000")
 
 
-REPORT_VERSION = "roi_report_v37"
-REPORT_RUN_SUFFIX = "r37"
-AGENCY_REPORT_VERSION = "roi_agency_advice_v7"
+REPORT_VERSION = "roi_report_v41"
+REPORT_RUN_SUFFIX = "r41"
+AGENCY_REPORT_VERSION = "roi_agency_advice_v9"
 
 
 T0_FISSION_MULTIPLIER_COLUMN = "裂变系数-总裂变UV/T0裂变UV"
 T0_FISSION_MULTIPLIER_COLUMN = "裂变系数-总裂变UV/T0裂变UV"
 TOTAL_FISSION_TO_FIRST_UV_COLUMN = DISPLAY_TOTAL_TO_FIRST_COLUMN
 TOTAL_FISSION_TO_FIRST_UV_COLUMN = DISPLAY_TOTAL_TO_FIRST_COLUMN
@@ -113,6 +113,7 @@ SUMMARY_METRICS = (
     "日均成本",
     "日均成本",
     T0_FISSION_MULTIPLIER_COLUMN,
     T0_FISSION_MULTIPLIER_COLUMN,
     TOTAL_FISSION_TO_FIRST_UV_COLUMN,
     TOTAL_FISSION_TO_FIRST_UV_COLUMN,
+    "三日均值ROI",
     "当日效率ROI",
     "当日效率ROI",
     "预测总效率ROI",
     "预测总效率ROI",
     "关停线",
     "关停线",
@@ -213,9 +214,12 @@ def _summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
     subset["日均T0裂变率"] = pd.to_numeric(
     subset["日均T0裂变率"] = pd.to_numeric(
         subset.get("三日加权平均T0裂变率"), errors="coerce"
         subset.get("三日加权平均T0裂变率"), errors="coerce"
     )
     )
-    subset["当日效率ROI"] = pd.to_numeric(
+    subset["三日均值ROI"] = pd.to_numeric(
         subset.get("三日加权平均实际ROI"), errors="coerce"
         subset.get("三日加权平均实际ROI"), errors="coerce"
     )
     )
+    subset["当日效率ROI"] = pd.to_numeric(
+        subset.get("最新日实际ROI"), errors="coerce"
+    )
     subset["预测总效率ROI"] = pd.to_numeric(
     subset["预测总效率ROI"] = pd.to_numeric(
         subset.get("三日加权平均效率ROI"), errors="coerce"
         subset.get("三日加权平均效率ROI"), errors="coerce"
     )
     )
@@ -276,11 +280,10 @@ def _summary_frame(rows: pd.DataFrame, entity_type: str) -> pd.DataFrame:
         subset["_说明排序"] = np.select(
         subset["_说明排序"] = np.select(
             [
             [
                 reason.str.contains("三日加权平均预测总效率ROI", regex=False),
                 reason.str.contains("三日加权平均预测总效率ROI", regex=False),
-                reason.str.contains("绝对低ROI候选线", regex=False),
-                reason.str.contains("单日高UV动态分位候选", regex=False),
+                reason.str.contains("同时满足ROI≤", regex=False),
             ],
             ],
-            [0, 1, 2],
-            default=3,
+            [0, 1],
+            default=2,
         )
         )
         roi_sort = pd.to_numeric(
         roi_sort = pd.to_numeric(
             subset["三日加权平均效率ROI"], errors="coerce"
             subset["三日加权平均效率ROI"], errors="coerce"
@@ -509,9 +512,9 @@ def _format_sheet(
             if first_observe_row:
             if first_observe_row:
                 add_top_separator(first_observe_row)
                 add_top_separator(first_observe_row)
 
 
-    for roi_header in ("当日效率ROI", "预测总效率ROI"):
+    for roi_header in ("三日均值ROI", "当日效率ROI", "预测总效率ROI"):
         roi_column = headers.get(roi_header)
         roi_column = headers.get(roi_header)
-        if not roi_column or max_row < 2:
+        if roi_header not in visible_columns or not roi_column or max_row < 2:
             continue
             continue
         roi_letter = get_column_letter(roi_column)
         roi_letter = get_column_letter(roi_column)
         ws.conditional_formatting.add(
         ws.conditional_formatting.add(
@@ -567,28 +570,14 @@ def _write_summary_sheet(
         ("小程序关停成本预算状态", self_threshold.get("关停成本预算状态")),
         ("小程序关停成本预算状态", self_threshold.get("关停成本预算状态")),
         ("小程序三日关停线", self_threshold.get("三日关停线")),
         ("小程序三日关停线", self_threshold.get("三日关停线")),
         ("小程序三日关停线分位点", self_threshold.get("三日关停线分位点")),
         ("小程序三日关停线分位点", self_threshold.get("三日关停线分位点")),
-        (
-            "小程序单日绝对低ROI关停线",
-            self_threshold.get("单日绝对低ROI关停线"),
-        ),
-        (
-            "小程序单日绝对低ROI关停线分位点",
-            self_threshold.get("单日绝对低ROI关停线分位点"),
-        ),
-        ("小程序单日高UV关停线", self_threshold.get("单日高UV关停线")),
-        (
-            "小程序单日高UV关停线分位点",
-            self_threshold.get("单日高UV关停线分位点"),
-        ),
+        ("小程序单日P10线", self_threshold.get("单日P10线")),
+        ("小程序单日合并资格线", self_threshold.get("单日合并资格线")),
+        ("小程序单日实际关停线", self_threshold.get("单日实际关停线")),
+        ("小程序单日实际关停线分位点", self_threshold.get("单日实际关停线分位点")),
         ("小程序三日实际关停成本", self_threshold.get("三日实际关停成本")),
         ("小程序三日实际关停成本", self_threshold.get("三日实际关停成本")),
-        (
-            "小程序单日绝对低ROI实际关停成本",
-            self_threshold.get("单日绝对低ROI实际关停成本"),
-        ),
-        (
-            "小程序单日高UV实际关停成本",
-            self_threshold.get("单日高UV实际关停成本"),
-        ),
+        ("小程序单日实际关停成本", self_threshold.get("单日实际关停成本")),
+        ("小程序单日候选池样本数", self_threshold.get("单日候选池样本数")),
+        ("小程序单日合并候选数", self_threshold.get("单日合并候选数")),
         ("小程序阈值样本数", self_threshold.get("阈值样本数")),
         ("小程序阈值样本数", self_threshold.get("阈值样本数")),
         ("创意扩量线(P80)", self_threshold.get("t_up")),
         ("创意扩量线(P80)", self_threshold.get("t_up")),
         ("扩量样本数", self_threshold.get("扩量样本数")),
         ("扩量样本数", self_threshold.get("扩量样本数")),
@@ -603,8 +592,8 @@ def _write_summary_sheet(
         ("广告级", "直接按广告去重计算,复用小程序三日动态关停线但不进入成本预算样本池;低于关停线且广告age>3天时,审批后暂停整个广告"),
         ("广告级", "直接按广告去重计算,复用小程序三日动态关停线但不进入成本预算样本池;低于关停线且广告age>3天时,审批后暂停整个广告"),
         ("日均字段", "三日总量/3,缺失日按0"),
         ("日均字段", "三日总量/3,缺失日按0"),
         ("ROI与裂变率", "三日汇总分子/三日汇总分母的加权口径"),
         ("ROI与裂变率", "三日汇总分子/三日汇总分母的加权口径"),
-        ("小程序成本软预算", "使用T-1实际成本,目标占小程序创意级昨日总成本5%;三日持续低ROI、单日绝对低ROI、单日高UV低分位按6:2:2基础额度分配,未用额度只在低质候选间流转"),
-        ("单日补充规则", "非三日正式样本的小程序创意:最新日UV>200且ROI≤0.20进入绝对低ROI组;ROI>0.20且UV>500进入高UV组;两组均按ROI从低到高和昨日成本预算动态截断"),
+        ("小程序成本软预算", "使用T-1实际成本,目标占小程序创意级昨日总成本5%;三日持续低ROI与单日补充规则按6:4基础额度分配,未用额度只在低质候选间流转"),
+        ("单日补充规则", "严格排除已进入三日正式判断的小程序创意;剩余创意最新日UV>200、成本>0、ROI有效且广告age>3天时进入单日池,同时满足预测总效率ROI≤0.20和实体等权P10才成为关停候选"),
         ("补充观察", "其余非正式样本中最新日首层UV>200,置于汇总表末尾且不执行"),
         ("补充观察", "其余非正式样本中最新日首层UV>200,置于汇总表末尾且不执行"),
         ("配置快照", json.dumps(dict(config), ensure_ascii=False, default=str)),
         ("配置快照", json.dumps(dict(config), ensure_ascii=False, default=str)),
     ]
     ]

+ 99 - 127
examples/auto_put_ad_mini/roi_control/rules.py

@@ -19,12 +19,11 @@ from .metrics import (
 )
 )
 
 
 
 
-POLICY_VERSION = "roi_policy_v13"
-POLICY_RUN_SUFFIX = "p13"
+POLICY_VERSION = "roi_policy_v15"
+POLICY_RUN_SUFFIX = "p15"
 
 
 STOP_RULE_THREE_DAY = "三日持续低ROI"
 STOP_RULE_THREE_DAY = "三日持续低ROI"
-STOP_RULE_ONE_DAY_HARD = "单日绝对低ROI"
-STOP_RULE_ONE_DAY_HIGH_UV = "单日高UV低分位"
+STOP_RULE_ONE_DAY = "单日绝对低ROI且P10"
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -35,15 +34,14 @@ class RuleConfig:
     partner_min_daily_uv: float = 200
     partner_min_daily_uv: float = 200
     observe_min_latest_uv: float = 200
     observe_min_latest_uv: float = 200
     one_day_min_uv: float = 200
     one_day_min_uv: float = 200
-    one_day_high_uv_min_uv: float = 500
     one_day_hard_stop_roi: float = 0.20
     one_day_hard_stop_roi: float = 0.20
+    one_day_stop_quantile: float = 0.10
     self_stop_cost_soft_lower_ratio: float = 0.045
     self_stop_cost_soft_lower_ratio: float = 0.045
     self_stop_cost_target_ratio: float = 0.05
     self_stop_cost_target_ratio: float = 0.05
     self_stop_cost_soft_upper_ratio: float = 0.055
     self_stop_cost_soft_upper_ratio: float = 0.055
     self_stop_cost_hard_cap_ratio: float = 0.06
     self_stop_cost_hard_cap_ratio: float = 0.06
     self_stop_three_day_share: float = 0.60
     self_stop_three_day_share: float = 0.60
-    self_stop_one_day_hard_share: float = 0.20
-    self_stop_one_day_high_uv_share: float = 0.20
+    self_stop_one_day_share: float = 0.40
     stop_quantile: float = 0.20
     stop_quantile: float = 0.20
     up_quantile: float = 0.80
     up_quantile: float = 0.80
 
 
@@ -117,23 +115,6 @@ def observation_mask(
     )
     )
 
 
 
 
-def one_day_high_uv_pool_mask(
-    summary: pd.DataFrame,
-    config: RuleConfig,
-    expected_dates: list[str],
-) -> pd.Series:
-    latest = expected_dates[-1]
-    latest_roi = pd.to_numeric(summary[f"ROI_{latest}"], errors="coerce")
-    return (
-        summary["entity_type"].eq(ENTITY_SELF)
-        & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
-            config.one_day_high_uv_min_uv
-        )
-        & pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
-        & np.isfinite(latest_roi)
-    )
-
-
 def one_day_supplement_mask(
 def one_day_supplement_mask(
     summary: pd.DataFrame,
     summary: pd.DataFrame,
     config: RuleConfig,
     config: RuleConfig,
@@ -188,7 +169,6 @@ def compute_channel_thresholds(
         else np.nan
         else np.nan
     )
     )
     latest = expected_dates[-1]
     latest = expected_dates[-1]
-    high_uv_pool = one_day_high_uv_pool_mask(summary, config, expected_dates)
     return pd.DataFrame(
     return pd.DataFrame(
         [
         [
             {
             {
@@ -197,17 +177,16 @@ def compute_channel_thresholds(
                 "渠道": SELF_CHANNEL,
                 "渠道": SELF_CHANNEL,
                 "t_stop": np.nan,
                 "t_stop": np.nan,
                 "t_up": t_up,
                 "t_up": t_up,
-                "t_one_day_hard_stop": np.nan,
                 "t_one_day_stop": np.nan,
                 "t_one_day_stop": np.nan,
                 "关停线分位点": np.nan,
                 "关停线分位点": np.nan,
-                "单日绝对低ROI关停线分位点": np.nan,
-                "单日高UV关停线分位点": np.nan,
+                "单日实际关停线分位点": np.nan,
                 "阈值样本数": int(len(self_sample)),
                 "阈值样本数": int(len(self_sample)),
                 "扩量样本数": int(len(self_sample)),
                 "扩量样本数": int(len(self_sample)),
-                "单日高UV样本数": int(high_uv_pool.sum()),
+                "单日候选池样本数": 0,
                 "单日硬关停ROI": config.one_day_hard_stop_roi,
                 "单日硬关停ROI": config.one_day_hard_stop_roi,
+                "单日P10线": np.nan,
+                "单日合并资格线": np.nan,
                 "单日最低UV": config.one_day_min_uv,
                 "单日最低UV": config.one_day_min_uv,
-                "单日高UV最低UV": config.one_day_high_uv_min_uv,
                 "关停线口径": "T-1实际成本5%软预算动态线",
                 "关停线口径": "T-1实际成本5%软预算动态线",
                 "扩量线口径": (
                 "扩量线口径": (
                     f"合格小程序创意实体等权P{int(config.up_quantile * 100)}"
                     f"合格小程序创意实体等权P{int(config.up_quantile * 100)}"
@@ -220,19 +199,18 @@ def compute_channel_thresholds(
                 "渠道": GZH_CHANNEL,
                 "渠道": GZH_CHANNEL,
                 "t_stop": gzh_stop,
                 "t_stop": gzh_stop,
                 "t_up": np.nan,
                 "t_up": np.nan,
-                "t_one_day_hard_stop": np.nan,
                 "t_one_day_stop": np.nan,
                 "t_one_day_stop": np.nan,
                 "关停线分位点": (
                 "关停线分位点": (
                     config.stop_quantile if np.isfinite(gzh_stop) else np.nan
                     config.stop_quantile if np.isfinite(gzh_stop) else np.nan
                 ),
                 ),
-                "单日绝对低ROI关停线分位点": np.nan,
-                "单日高UV关停线分位点": np.nan,
+                "单日实际关停线分位点": np.nan,
                 "阈值样本数": int(len(gzh_sample)),
                 "阈值样本数": int(len(gzh_sample)),
                 "扩量样本数": 0,
                 "扩量样本数": 0,
-                "单日高UV样本数": 0,
+                "单日候选池样本数": 0,
                 "单日硬关停ROI": np.nan,
                 "单日硬关停ROI": np.nan,
+                "单日P10线": np.nan,
+                "单日合并资格线": np.nan,
                 "单日最低UV": np.nan,
                 "单日最低UV": np.nan,
-                "单日高UV最低UV": np.nan,
                 "关停线口径": (
                 "关停线口径": (
                     f"公众号合格实体等权P{int(config.stop_quantile * 100)}"
                     f"公众号合格实体等权P{int(config.stop_quantile * 100)}"
                 ),
                 ),
@@ -314,36 +292,46 @@ def _allocate_self_stop_budget(
     latest_roi_column = f"ROI_{latest}"
     latest_roi_column = f"ROI_{latest}"
     latest_cost = pd.to_numeric(result[cost_column], errors="coerce").fillna(0.0)
     latest_cost = pd.to_numeric(result[cost_column], errors="coerce").fillna(0.0)
     latest_roi = pd.to_numeric(result[latest_roi_column], errors="coerce")
     latest_roi = pd.to_numeric(result[latest_roi_column], errors="coerce")
-    latest_uv = pd.to_numeric(result[f"首层UV_{latest}"], errors="coerce")
     age = pd.to_numeric(result.get("广告age"), errors="coerce").fillna(0)
     age = pd.to_numeric(result.get("广告age"), errors="coerce").fillna(0)
     actionable_age = age.ge(config.self_stop_min_age)
     actionable_age = age.ge(config.self_stop_min_age)
 
 
     three_day_mask = formal_creative & actionable_age
     three_day_mask = formal_creative & actionable_age
-    one_day_hard_mask = (
-        one_day_supplement
-        & actionable_age
-        & latest_roi.le(config.one_day_hard_stop_roi)
+    one_day_pool = one_day_supplement & actionable_age
+    one_day_sample = latest_roi.loc[one_day_pool]
+    one_day_sample = one_day_sample[np.isfinite(one_day_sample)]
+    one_day_p10_line = (
+        float(one_day_sample.quantile(config.one_day_stop_quantile))
+        if not one_day_sample.empty
+        else np.nan
+    )
+    one_day_eligibility_line = (
+        min(config.one_day_hard_stop_roi, one_day_p10_line)
+        if np.isfinite(one_day_p10_line)
+        else np.nan
     )
     )
-    one_day_high_uv_mask = (
-        one_day_supplement
-        & actionable_age
-        & latest_uv.gt(config.one_day_high_uv_min_uv)
-        & latest_roi.gt(config.one_day_hard_stop_roi)
+    one_day_candidate = (
+        one_day_pool
+        & latest_roi.le(config.one_day_hard_stop_roi)
+        & latest_roi.le(one_day_p10_line)
     )
     )
+    result["单日ROI排名百分位"] = np.nan
+    if not one_day_sample.empty:
+        result.loc[one_day_pool, "单日ROI排名百分位"] = latest_roi.loc[
+            one_day_pool
+        ].apply(lambda value: _sample_percentile(one_day_sample, float(value)))
+    result["单日P10线"] = one_day_p10_line
+    result["单日合并资格线"] = one_day_eligibility_line
     masks = {
     masks = {
         STOP_RULE_THREE_DAY: three_day_mask,
         STOP_RULE_THREE_DAY: three_day_mask,
-        STOP_RULE_ONE_DAY_HARD: one_day_hard_mask,
-        STOP_RULE_ONE_DAY_HIGH_UV: one_day_high_uv_mask,
+        STOP_RULE_ONE_DAY: one_day_candidate,
     }
     }
     roi_columns = {
     roi_columns = {
         STOP_RULE_THREE_DAY: "ROI",
         STOP_RULE_THREE_DAY: "ROI",
-        STOP_RULE_ONE_DAY_HARD: latest_roi_column,
-        STOP_RULE_ONE_DAY_HIGH_UV: latest_roi_column,
+        STOP_RULE_ONE_DAY: latest_roi_column,
     }
     }
     shares = {
     shares = {
         STOP_RULE_THREE_DAY: config.self_stop_three_day_share,
         STOP_RULE_THREE_DAY: config.self_stop_three_day_share,
-        STOP_RULE_ONE_DAY_HARD: config.self_stop_one_day_hard_share,
-        STOP_RULE_ONE_DAY_HIGH_UV: config.self_stop_one_day_high_uv_share,
+        STOP_RULE_ONE_DAY: config.self_stop_one_day_share,
     }
     }
     all_self = result["entity_type"].eq(ENTITY_SELF)
     all_self = result["entity_type"].eq(ENTITY_SELF)
     total_cost = float(latest_cost.loc[all_self & latest_cost.gt(0)].sum())
     total_cost = float(latest_cost.loc[all_self & latest_cost.gt(0)].sum())
@@ -362,11 +350,7 @@ def _allocate_self_stop_budget(
         for rule, mask in masks.items()
         for rule, mask in masks.items()
     }
     }
     selected: set[object] = set()
     selected: set[object] = set()
-    for rule in (
-        STOP_RULE_THREE_DAY,
-        STOP_RULE_ONE_DAY_HARD,
-        STOP_RULE_ONE_DAY_HIGH_UV,
-    ):
+    for rule in (STOP_RULE_THREE_DAY, STOP_RULE_ONE_DAY):
         selected.update(
         selected.update(
             _select_prefix_within_budget(
             _select_prefix_within_budget(
                 result,
                 result,
@@ -377,11 +361,7 @@ def _allocate_self_stop_budget(
         )
         )
 
 
     selected_cost = float(latest_cost.loc[list(selected)].sum()) if selected else 0.0
     selected_cost = float(latest_cost.loc[list(selected)].sum()) if selected else 0.0
-    for rule in (
-        STOP_RULE_ONE_DAY_HARD,
-        STOP_RULE_THREE_DAY,
-        STOP_RULE_ONE_DAY_HIGH_UV,
-    ):
+    for rule in (STOP_RULE_ONE_DAY, STOP_RULE_THREE_DAY):
         for index in ordered[rule]:
         for index in ordered[rule]:
             if index in selected:
             if index in selected:
                 continue
                 continue
@@ -404,11 +384,7 @@ def _allocate_self_stop_budget(
     # allow the next uninterrupted ROI prefix to approach 5%, but never exceed
     # allow the next uninterrupted ROI prefix to approach 5%, but never exceed
     # the 6% absolute cap.
     # the 6% absolute cap.
     if selected_cost < soft_lower_cost:
     if selected_cost < soft_lower_cost:
-        for rule in (
-            STOP_RULE_ONE_DAY_HARD,
-            STOP_RULE_THREE_DAY,
-            STOP_RULE_ONE_DAY_HIGH_UV,
-        ):
+        for rule in (STOP_RULE_ONE_DAY, STOP_RULE_THREE_DAY):
             for index in ordered[rule]:
             for index in ordered[rule]:
                 if index in selected:
                 if index in selected:
                     continue
                     continue
@@ -464,28 +440,18 @@ def _allocate_self_stop_budget(
             else "高于软上限"
             else "高于软上限"
         ),
         ),
         "三日基础预算成本": target_cost * config.self_stop_three_day_share,
         "三日基础预算成本": target_cost * config.self_stop_three_day_share,
-        "单日绝对低ROI基础预算成本": (
-            target_cost * config.self_stop_one_day_hard_share
-        ),
-        "单日高UV基础预算成本": (
-            target_cost * config.self_stop_one_day_high_uv_share
-        ),
+        "单日基础预算成本": target_cost * config.self_stop_one_day_share,
         "三日候选数": len(ordered[STOP_RULE_THREE_DAY]),
         "三日候选数": len(ordered[STOP_RULE_THREE_DAY]),
-        "单日绝对低ROI候选数": len(ordered[STOP_RULE_ONE_DAY_HARD]),
-        "单日高UV候选数": len(ordered[STOP_RULE_ONE_DAY_HIGH_UV]),
+        "单日候选池样本数": int(one_day_pool.sum()),
+        "单日合并候选数": len(ordered[STOP_RULE_ONE_DAY]),
+        "单日P10线": one_day_p10_line,
+        "单日合并资格线": one_day_eligibility_line,
         "三日实际关停成本": lines[f"{STOP_RULE_THREE_DAY}_cost"],
         "三日实际关停成本": lines[f"{STOP_RULE_THREE_DAY}_cost"],
-        "单日绝对低ROI实际关停成本": lines[f"{STOP_RULE_ONE_DAY_HARD}_cost"],
-        "单日高UV实际关停成本": lines[f"{STOP_RULE_ONE_DAY_HIGH_UV}_cost"],
+        "单日实际关停成本": lines[f"{STOP_RULE_ONE_DAY}_cost"],
         "三日关停线": lines[f"{STOP_RULE_THREE_DAY}_line"],
         "三日关停线": lines[f"{STOP_RULE_THREE_DAY}_line"],
         "三日关停线分位点": lines[f"{STOP_RULE_THREE_DAY}_percentile"],
         "三日关停线分位点": lines[f"{STOP_RULE_THREE_DAY}_percentile"],
-        "单日绝对低ROI关停线": lines[f"{STOP_RULE_ONE_DAY_HARD}_line"],
-        "单日绝对低ROI关停线分位点": lines[
-            f"{STOP_RULE_ONE_DAY_HARD}_percentile"
-        ],
-        "单日高UV关停线": lines[f"{STOP_RULE_ONE_DAY_HIGH_UV}_line"],
-        "单日高UV关停线分位点": lines[
-            f"{STOP_RULE_ONE_DAY_HIGH_UV}_percentile"
-        ],
+        "单日实际关停线": lines[f"{STOP_RULE_ONE_DAY}_line"],
+        "单日实际关停线分位点": lines[f"{STOP_RULE_ONE_DAY}_percentile"],
     }
     }
     return selected_mask, candidate_rule, stats
     return selected_mask, candidate_rule, stats
 
 
@@ -554,7 +520,11 @@ def apply_actions(
             >= result.loc[creative_eligible, "t_up"]
             >= result.loc[creative_eligible, "t_up"]
         )
         )
 
 
-    selected_stop, candidate_rule, budget_stats = _allocate_self_stop_budget(
+    (
+        selected_stop,
+        candidate_rule,
+        budget_stats,
+    ) = _allocate_self_stop_budget(
         result,
         result,
         config,
         config,
         expected_dates,
         expected_dates,
@@ -565,13 +535,9 @@ def apply_actions(
     threshold_updates = {
     threshold_updates = {
         **budget_stats,
         **budget_stats,
         "t_stop": budget_stats["三日关停线"],
         "t_stop": budget_stats["三日关停线"],
-        "t_one_day_hard_stop": budget_stats["单日绝对低ROI关停线"],
-        "t_one_day_stop": budget_stats["单日高UV关停线"],
+        "t_one_day_stop": budget_stats["单日实际关停线"],
         "关停线分位点": budget_stats["三日关停线分位点"],
         "关停线分位点": budget_stats["三日关停线分位点"],
-        "单日绝对低ROI关停线分位点": budget_stats[
-            "单日绝对低ROI关停线分位点"
-        ],
-        "单日高UV关停线分位点": budget_stats["单日高UV关停线分位点"],
+        "单日实际关停线分位点": budget_stats["单日实际关停线分位点"],
     }
     }
     for column, value in threshold_updates.items():
     for column, value in threshold_updates.items():
         thresholds.loc[self_row, column] = value
         thresholds.loc[self_row, column] = value
@@ -593,18 +559,11 @@ def apply_actions(
     self_or_ad = result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
     self_or_ad = result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
     result.loc[self_or_ad, "t_stop"] = formal_line
     result.loc[self_or_ad, "t_stop"] = formal_line
     result.loc[self_or_ad, "关停线分位点"] = formal_percentile
     result.loc[self_or_ad, "关停线分位点"] = formal_percentile
-    hard_rows = candidate_rule.eq(STOP_RULE_ONE_DAY_HARD)
-    high_uv_rows = candidate_rule.eq(STOP_RULE_ONE_DAY_HIGH_UV)
-    result.loc[hard_rows, "t_stop"] = budget_stats["单日绝对低ROI关停线"]
-    result.loc[hard_rows, "关停线分位点"] = budget_stats[
-        "单日绝对低ROI关停线分位点"
-    ]
-    result.loc[high_uv_rows, "t_stop"] = budget_stats["单日高UV关停线"]
-    result.loc[high_uv_rows, "t_one_day_stop"] = budget_stats[
-        "单日高UV关停线"
-    ]
-    result.loc[high_uv_rows, "关停线分位点"] = budget_stats[
-        "单日高UV关停线分位点"
+    one_day_rows = candidate_rule.eq(STOP_RULE_ONE_DAY)
+    result.loc[one_day_rows, "t_stop"] = budget_stats["单日实际关停线"]
+    result.loc[one_day_rows, "t_one_day_stop"] = budget_stats["单日实际关停线"]
+    result.loc[one_day_rows, "关停线分位点"] = budget_stats[
+        "单日实际关停线分位点"
     ]
     ]
     result.loc[selected_stop & creative_eligible, "是否低于三日关停线"] = True
     result.loc[selected_stop & creative_eligible, "是否低于三日关停线"] = True
     result.loc[gzh_eligible, "关停线分位点"] = float(
     result.loc[gzh_eligible, "关停线分位点"] = float(
@@ -631,22 +590,27 @@ def apply_actions(
                 f";三日预测总效率ROI={three_day_roi:.2f}仅作参考,"
                 f";三日预测总效率ROI={three_day_roi:.2f}仅作参考,"
                 "不参与本次单日判断"
                 "不参与本次单日判断"
             )
             )
-            stop_reason = ""
-            if latest_roi <= config.one_day_hard_stop_roi:
-                stop_reason = (
-                    f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
-                    f"最新日预测总效率ROI={latest_roi:.2f}≤绝对低ROI候选线"
-                    f"{config.one_day_hard_stop_roi:.2f}"
-                )
-            elif (
-                latest_uv > config.one_day_high_uv_min_uv
-            ):
-                stop_reason = (
-                    f"最新日首层UV={latest_uv:.0f}>{config.one_day_high_uv_min_uv:g},"
-                    f"进入单日高UV动态分位候选,最新日预测总效率ROI={latest_roi:.2f}"
-                )
+            one_day_p10_line = float(budget_stats["单日P10线"])
+            meets_absolute = latest_roi <= config.one_day_hard_stop_roi
+            meets_p10 = (
+                np.isfinite(one_day_p10_line)
+                and latest_roi <= one_day_p10_line
+            )
+            qualifies = meets_absolute and meets_p10
+            stop_reason = (
+                f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
+                f"最新日预测总效率ROI={latest_roi:.2f}同时满足ROI≤"
+                f"{config.one_day_hard_stop_roi:.2f}和单日候选池P10线"
+                f"{one_day_p10_line:.2f}"
+            )
 
 
-            if bool(selected_stop.loc[index]):
+            if qualifies and age < config.self_stop_min_age:
+                result.at[index, "动作"] = "观察"
+                result.at[index, "动作原因"] = (
+                    f"{decision_context}{stop_reason}{three_day_context};"
+                    f"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
+                )
+            elif bool(selected_stop.loc[index]):
                 result.at[index, "动作"] = "关停"
                 result.at[index, "动作"] = "关停"
                 result.at[index, "动作原因"] = (
                 result.at[index, "动作原因"] = (
                     f"{decision_context}{stop_reason}{three_day_context};"
                     f"{decision_context}{stop_reason}{three_day_context};"
@@ -655,25 +619,33 @@ def apply_actions(
                     f"按5%成本软预算选中({candidate_rule.loc[index]});"
                     f"按5%成本软预算选中({candidate_rule.loc[index]});"
                     "建议审批后暂停动态创意"
                     "建议审批后暂停动态创意"
                 )
                 )
-            elif stop_reason and age < config.self_stop_min_age:
+            elif qualifies:
                 result.at[index, "动作"] = "观察"
                 result.at[index, "动作"] = "观察"
                 result.at[index, "动作原因"] = (
                 result.at[index, "动作原因"] = (
                     f"{decision_context}{stop_reason}{three_day_context};"
                     f"{decision_context}{stop_reason}{three_day_context};"
-                    f"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
+                    f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
+                    "满足低质候选条件但未被5%成本软预算选中;建议观察"
                 )
                 )
-            elif stop_reason:
+            elif not meets_absolute:
                 result.at[index, "动作"] = "观察"
                 result.at[index, "动作"] = "观察"
                 result.at[index, "动作原因"] = (
                 result.at[index, "动作原因"] = (
-                    f"{decision_context}{stop_reason}{three_day_context};"
-                    f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
-                    "满足低质候选条件但未被5%成本软预算选中;建议观察"
+                    f"{decision_context}最新日首层UV={latest_uv:.0f}>"
+                    f"{config.one_day_min_uv:g},最新日预测总效率ROI="
+                    f"{latest_roi:.2f}高于绝对线{config.one_day_hard_stop_roi:.2f},"
+                    f"不满足单日两个条件{three_day_context};建议继续观察"
+                )
+            elif not meets_p10:
+                result.at[index, "动作"] = "观察"
+                result.at[index, "动作原因"] = (
+                    f"{decision_context}最新日预测总效率ROI={latest_roi:.2f}≤"
+                    f"{config.one_day_hard_stop_roi:.2f},但高于单日候选池P10线"
+                    f"{one_day_p10_line:.2f}、未进入后10%{three_day_context};"
+                    "建议继续观察"
                 )
                 )
             else:
             else:
                 result.at[index, "动作"] = "观察"
                 result.at[index, "动作"] = "观察"
                 result.at[index, "动作原因"] = (
                 result.at[index, "动作原因"] = (
-                    f"{decision_context}"
-                    f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
-                    f"最新日预测总效率ROI={latest_roi:.2f}未命中单日关停规则"
+                    f"{decision_context}单日候选池不足,无法计算P10线"
                     f"{three_day_context};建议继续观察"
                     f"{three_day_context};建议继续观察"
                 )
                 )
             continue
             continue

+ 2 - 3
examples/auto_put_ad_mini/roi_control/service.py

@@ -161,15 +161,14 @@ def _rule_config(config: RoiConfig) -> RuleConfig:
         partner_min_daily_uv=config.partner_min_daily_uv,
         partner_min_daily_uv=config.partner_min_daily_uv,
         observe_min_latest_uv=config.observe_min_latest_uv,
         observe_min_latest_uv=config.observe_min_latest_uv,
         one_day_min_uv=config.one_day_min_uv,
         one_day_min_uv=config.one_day_min_uv,
-        one_day_high_uv_min_uv=config.one_day_high_uv_min_uv,
         one_day_hard_stop_roi=config.one_day_hard_stop_roi,
         one_day_hard_stop_roi=config.one_day_hard_stop_roi,
+        one_day_stop_quantile=config.one_day_stop_quantile,
         self_stop_cost_soft_lower_ratio=config.self_stop_cost_soft_lower_ratio,
         self_stop_cost_soft_lower_ratio=config.self_stop_cost_soft_lower_ratio,
         self_stop_cost_target_ratio=config.self_stop_cost_target_ratio,
         self_stop_cost_target_ratio=config.self_stop_cost_target_ratio,
         self_stop_cost_soft_upper_ratio=config.self_stop_cost_soft_upper_ratio,
         self_stop_cost_soft_upper_ratio=config.self_stop_cost_soft_upper_ratio,
         self_stop_cost_hard_cap_ratio=config.self_stop_cost_hard_cap_ratio,
         self_stop_cost_hard_cap_ratio=config.self_stop_cost_hard_cap_ratio,
         self_stop_three_day_share=config.self_stop_three_day_share,
         self_stop_three_day_share=config.self_stop_three_day_share,
-        self_stop_one_day_hard_share=config.self_stop_one_day_hard_share,
-        self_stop_one_day_high_uv_share=config.self_stop_one_day_high_uv_share,
+        self_stop_one_day_share=config.self_stop_one_day_share,
         stop_quantile=config.stop_quantile,
         stop_quantile=config.stop_quantile,
         up_quantile=config.up_quantile,
         up_quantile=config.up_quantile,
     )
     )

+ 113 - 45
examples/auto_put_ad_mini/test_roi_control_metrics.py

@@ -30,6 +30,7 @@ from roi_control.reporting import (
     AGENCY_SUMMARY_SHEETS,
     AGENCY_SUMMARY_SHEETS,
     DAILY_SHEETS,
     DAILY_SHEETS,
     SUMMARY_SHEETS,
     SUMMARY_SHEETS,
+    _agency_summary_frame,
     _daily_frame,
     _daily_frame,
     _summary_frame,
     _summary_frame,
     _visible_columns,
     _visible_columns,
@@ -154,8 +155,8 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         self.assertEqual(date_window("20260722"), ("20260720", "20260722"))
         self.assertEqual(date_window("20260722"), ("20260720", "20260722"))
         run_id, _ = _run_identity("20260722", FISSION_PARAMETERS)
         run_id, _ = _run_identity("20260722", FISSION_PARAMETERS)
         self.assertIn("m8", run_id)
         self.assertIn("m8", run_id)
-        self.assertIn("p13", run_id)
-        self.assertIn("r37", run_id)
+        self.assertIn("p15", run_id)
+        self.assertIn("r41", run_id)
 
 
     def test_current_creative_status_only_reads_stop_decisions(self):
     def test_current_creative_status_only_reads_stop_decisions(self):
         rows = pd.DataFrame(
         rows = pd.DataFrame(
@@ -227,7 +228,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             pool["阈值样本状态"].eq("进入三日渠道独立阈值样本池").all()
             pool["阈值样本状态"].eq("进入三日渠道独立阈值样本池").all()
         )
         )
 
 
-    def test_self_stop_budget_uses_latest_cost_with_six_two_two_soft_allocation(self):
+    def test_self_stop_budget_uses_latest_cost_with_six_four_soft_allocation(self):
         rows = []
         rows = []
         formal = [
         formal = [
             ("formal-1", 0.10, 30.0),
             ("formal-1", 0.10, 30.0),
@@ -250,7 +251,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             [
             [
                 row(ENTITY_SELF, "hard-1", DATES[-1], 0.05, uv=300, cost=20.0),
                 row(ENTITY_SELF, "hard-1", DATES[-1], 0.05, uv=300, cost=20.0),
                 row(ENTITY_SELF, "hard-2", DATES[-1], 0.10, uv=300, cost=200.0),
                 row(ENTITY_SELF, "hard-2", DATES[-1], 0.10, uv=300, cost=200.0),
-                row(ENTITY_SELF, "rank-1", DATES[-1], 0.30, uv=600, cost=20.0),
+                row(ENTITY_SELF, "rank-1", DATES[-1], 0.05, uv=600, cost=20.0),
                 row(ENTITY_SELF, "rank-2", DATES[-1], 0.40, uv=600, cost=700.0),
                 row(ENTITY_SELF, "rank-2", DATES[-1], 0.40, uv=600, cost=700.0),
             ]
             ]
         )
         )
@@ -281,18 +282,17 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         self.assertAlmostEqual(float(self_threshold["实际关停成本占比"]), 0.05)
         self.assertAlmostEqual(float(self_threshold["实际关停成本占比"]), 0.05)
         self.assertEqual(self_threshold["关停成本预算状态"], "正常范围")
         self.assertEqual(self_threshold["关停成本预算状态"], "正常范围")
         self.assertAlmostEqual(float(self_threshold["三日基础预算成本"]), 60.0)
         self.assertAlmostEqual(float(self_threshold["三日基础预算成本"]), 60.0)
-        self.assertAlmostEqual(float(self_threshold["单日绝对低ROI基础预算成本"]), 20.0)
-        self.assertAlmostEqual(float(self_threshold["单日高UV基础预算成本"]), 20.0)
+        self.assertAlmostEqual(float(self_threshold["单日基础预算成本"]), 40.0)
         self.assertAlmostEqual(float(self_threshold["三日关停线"]), 0.20)
         self.assertAlmostEqual(float(self_threshold["三日关停线"]), 0.20)
         self.assertAlmostEqual(float(self_threshold["三日关停线分位点"]), 0.50)
         self.assertAlmostEqual(float(self_threshold["三日关停线分位点"]), 0.50)
-        self.assertAlmostEqual(float(self_threshold["单日绝对低ROI关停线"]), 0.05)
-        self.assertAlmostEqual(float(self_threshold["单日高UV关停线"]), 0.30)
+        self.assertAlmostEqual(float(self_threshold["单日P10线"]), 0.05)
+        self.assertAlmostEqual(float(self_threshold["单日合并资格线"]), 0.05)
+        self.assertAlmostEqual(float(self_threshold["单日实际关停线"]), 0.05)
         self.assertEqual(
         self.assertEqual(
             selected.groupby("关停规则")[f"成本_{DATES[-1]}"].sum().to_dict(),
             selected.groupby("关停规则")[f"成本_{DATES[-1]}"].sum().to_dict(),
             {
             {
                 "三日持续低ROI": 60.0,
                 "三日持续低ROI": 60.0,
-                "单日绝对低ROI": 20.0,
-                "单日高UV低分位": 20.0,
+                "单日绝对低ROI且P10": 40.0,
             },
             },
         )
         )
 
 
@@ -339,7 +339,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             ).all()
             ).all()
         )
         )
 
 
-    def test_unused_group_budget_rolls_to_remaining_low_roi_candidate(self):
+    def test_unused_one_day_budget_rolls_to_three_day_candidates(self):
         rows = []
         rows = []
         formal = [
         formal = [
             ("formal-roll-1", 0.10, 60.0),
             ("formal-roll-1", 0.10, 60.0),
@@ -359,7 +359,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
                 )
                 )
         rows.extend(
         rows.extend(
             [
             [
-                row(ENTITY_SELF, "rank-roll-1", DATES[-1], 0.30, uv=600, cost=20),
+                row(ENTITY_SELF, "rank-roll-1", DATES[-1], 0.05, uv=600, cost=20),
                 row(ENTITY_SELF, "rank-roll-2", DATES[-1], 0.40, uv=600, cost=1000),
                 row(ENTITY_SELF, "rank-roll-2", DATES[-1], 0.40, uv=600, cost=1000),
             ]
             ]
         )
         )
@@ -384,7 +384,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         self.assertAlmostEqual(float(self_threshold["实际关停成本"]), 100.0)
         self.assertAlmostEqual(float(self_threshold["实际关停成本"]), 100.0)
         self.assertAlmostEqual(float(self_threshold["三日基础预算成本"]), 60.0)
         self.assertAlmostEqual(float(self_threshold["三日基础预算成本"]), 60.0)
         self.assertAlmostEqual(float(self_threshold["三日实际关停成本"]), 80.0)
         self.assertAlmostEqual(float(self_threshold["三日实际关停成本"]), 80.0)
-        self.assertAlmostEqual(float(self_threshold["单日高UV实际关停成本"]), 20.0)
+        self.assertAlmostEqual(float(self_threshold["单日实际关停成本"]), 20.0)
         self.assertEqual(self_threshold["关停成本预算状态"], "正常范围")
         self.assertEqual(self_threshold["关停成本预算状态"], "正常范围")
 
 
     def test_creative_p80_scale_requires_ad_age_at_least_three_days(self):
     def test_creative_p80_scale_requires_ad_age_at_least_three_days(self):
@@ -430,14 +430,14 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         self.assertEqual(target["日均首层UV"], 100)
         self.assertEqual(target["日均首层UV"], 100)
         self.assertTrue(candidates["广告id"].eq("latest-only").any())
         self.assertTrue(candidates["广告id"].eq("latest-only").any())
 
 
-    def test_one_day_supplement_requires_ad_age_over_three_days(self):
+    def test_one_day_rule_requires_absolute_low_roi_and_p10_and_age(self):
         daily = self.build_daily()
         daily = self.build_daily()
         supplemental = [
         supplemental = [
-            row(ENTITY_SELF, "one-day-hard", DATES[-1], 0.2, uv=300, cost=10),
-            row(ENTITY_SELF, "one-day-p30", DATES[-1], 0.5, uv=600, cost=10),
-            row(ENTITY_SELF, "one-day-young", DATES[-1], 0.6, uv=600, cost=10),
-            row(ENTITY_SELF, "one-day-mid", DATES[-1], 8.0, uv=600),
-            row(ENTITY_SELF, "one-day-high", DATES[-1], 9.0, uv=600, cost=900),
+            row(ENTITY_SELF, "one-day-both", DATES[-1], 0.05, uv=300, cost=10),
+            row(ENTITY_SELF, "one-day-not-p10", DATES[-1], 0.15, uv=300, cost=10),
+            row(ENTITY_SELF, "one-day-young", DATES[-1], 0.01, uv=300, cost=10),
+            row(ENTITY_SELF, "one-day-mid", DATES[-1], 1.0, uv=300, cost=10),
+            row(ENTITY_SELF, "one-day-high", DATES[-1], 2.0, uv=300, cost=900),
         ]
         ]
         daily = pd.concat([daily, pd.DataFrame(supplemental)], ignore_index=True)
         daily = pd.concat([daily, pd.DataFrame(supplemental)], ignore_index=True)
         ages = pd.concat(
         ages = pd.concat(
@@ -446,8 +446,8 @@ class RoiThreeDayRulesTest(unittest.TestCase):
                 pd.DataFrame(
                 pd.DataFrame(
                     {
                     {
                         "广告id": [
                         "广告id": [
-                            "one-day-hard",
-                            "one-day-p30",
+                            "one-day-both",
+                            "one-day-not-p10",
                             "one-day-young",
                             "one-day-young",
                             "one-day-mid",
                             "one-day-mid",
                             "one-day-high",
                             "one-day-high",
@@ -460,33 +460,28 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         )
         )
         _, thresholds, summary = evaluate_rules(daily, DATES, ages)
         _, thresholds, summary = evaluate_rules(daily, DATES, ages)
         self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
         self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
-        self.assertEqual(int(self_threshold["单日高UV样本数"]), 8)
-        self.assertAlmostEqual(float(self_threshold["t_one_day_stop"]), 0.5)
+        self.assertEqual(int(self_threshold["单日候选池样本数"]), 4)
+        self.assertAlmostEqual(float(self_threshold["单日P10线"]), 0.08)
+        self.assertAlmostEqual(float(self_threshold["单日合并资格线"]), 0.08)
         targets = summary.set_index("广告id")
         targets = summary.set_index("广告id")
-        self.assertEqual(targets.loc["one-day-hard", "动作"], "关停")
-        self.assertIn("绝对低ROI候选线", targets.loc["one-day-hard", "动作原因"])
-        self.assertIn("启用单日补充规则", targets.loc["one-day-hard", "动作原因"])
-        self.assertIn("最新日预测总效率ROI", targets.loc["one-day-hard", "动作原因"])
-        self.assertIn("三日预测总效率ROI", targets.loc["one-day-hard", "动作原因"])
-        self.assertIn("不参与本次单日判断", targets.loc["one-day-hard", "动作原因"])
-        self.assertIn("广告age=4>3天", targets.loc["one-day-hard", "动作原因"])
-        self.assertEqual(targets.loc["one-day-p30", "动作"], "关停")
-        self.assertIn("单日高UV动态分位候选", targets.loc["one-day-p30", "动作原因"])
+        self.assertEqual(targets.loc["one-day-both", "动作"], "关停")
+        self.assertEqual(
+            targets.loc["one-day-both", "关停规则"],
+            "单日绝对低ROI且P10",
+        )
+        self.assertIn("同时满足", targets.loc["one-day-both", "动作原因"])
+        self.assertIn("ROI≤0.20", targets.loc["one-day-both", "动作原因"])
+        self.assertIn("P10", targets.loc["one-day-both", "动作原因"])
+        self.assertEqual(targets.loc["one-day-not-p10", "动作"], "观察")
+        self.assertIn("未进入后10%", targets.loc["one-day-not-p10", "动作原因"])
         self.assertEqual(targets.loc["one-day-young", "动作"], "观察")
         self.assertEqual(targets.loc["one-day-young", "动作"], "观察")
         self.assertIn("广告age=3≤3天", targets.loc["one-day-young", "动作原因"])
         self.assertIn("广告age=3≤3天", targets.loc["one-day-young", "动作原因"])
-        self.assertEqual(targets.loc["one-day-mid", "动作"], "观察")
-        self.assertIn(
-            "未被5%成本软预算选中",
-            targets.loc["one-day-mid", "动作原因"],
-        )
         creative_frame = _summary_frame(summary, ENTITY_SELF)
         creative_frame = _summary_frame(summary, ENTITY_SELF)
         creative_stops = creative_frame[
         creative_stops = creative_frame[
             creative_frame["建议动作"].eq("关停创意")
             creative_frame["建议动作"].eq("关停创意")
         ]
         ]
-        self.assertEqual(
-            creative_stops["广告id"].tolist(),
-            ["one-day-hard", "one-day-p30"],
-        )
+        self.assertIn("one-day-both", creative_stops["广告id"].tolist())
+        self.assertNotIn("one-day-not-p10", creative_stops["广告id"].tolist())
         self.assertTrue(creative_stops["动作"].eq("关停").all())
         self.assertTrue(creative_stops["动作"].eq("关停").all())
         self.assertTrue(
         self.assertTrue(
             summary[
             summary[
@@ -495,6 +490,43 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             ]["广告age"].ge(4).all()
             ]["广告age"].ge(4).all()
         )
         )
 
 
+    def test_one_day_p10_without_absolute_low_roi_is_observe(self):
+        daily = self.build_daily()
+        supplemental = [
+            row(ENTITY_SELF, f"one-day-high-{index}", DATES[-1], roi, uv=300, cost=10)
+            for index, roi in enumerate([0.30, 0.40, 0.50, 0.60, 0.70])
+        ]
+        daily = pd.concat([daily, pd.DataFrame(supplemental)], ignore_index=True)
+        ages = pd.concat(
+            [
+                self.ages(),
+                pd.DataFrame(
+                    {
+                        "广告id": [f"one-day-high-{index}" for index in range(5)],
+                        "广告age": [10] * 5,
+                    }
+                ),
+            ],
+            ignore_index=True,
+        )
+
+        _, thresholds, summary = evaluate_rules(daily, DATES, ages)
+        self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
+        target = summary[summary["广告id"].eq("one-day-high-0")].iloc[0]
+        self.assertGreater(float(self_threshold["单日P10线"]), 0.20)
+        self.assertAlmostEqual(float(self_threshold["单日合并资格线"]), 0.20)
+        self.assertEqual(target["动作"], "观察")
+        self.assertIn("高于绝对线0.20", target["动作原因"])
+
+    def test_three_day_formal_entity_never_reenters_one_day_rule(self):
+        daily = self.build_daily()
+        ages = self.ages()
+        _, _, summary = evaluate_rules(daily, DATES, ages)
+        target = summary[summary["广告id"].eq("creative-ad-0")].iloc[0]
+        self.assertEqual(target["阈值样本状态"], "进入三日渠道独立阈值样本池")
+        self.assertNotEqual(target["关停规则"], "单日绝对低ROI且P10")
+        self.assertNotIn("单日", target["动作原因"])
+
     def test_daily_prediction_uses_same_day_t0_fission_revenue_once(self):
     def test_daily_prediction_uses_same_day_t0_fission_revenue_once(self):
         raw = pd.DataFrame([row(ENTITY_SELF, "formula", DATES[-1], 0)])
         raw = pd.DataFrame([row(ENTITY_SELF, "formula", DATES[-1], 0)])
         raw.loc[0, ["成本", "效率收入", "裂变效率收入"]] = [100, 50, 30]
         raw.loc[0, ["成本", "效率收入", "裂变效率收入"]] = [100, 50, 30]
@@ -669,6 +701,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         )
         )
         self.assertIn("日均T0裂变率", frame.columns)
         self.assertIn("日均T0裂变率", frame.columns)
         self.assertNotIn("三日加权平均T0裂变率", _visible_columns(SUMMARY_SHEETS[ENTITY_SELF]))
         self.assertNotIn("三日加权平均T0裂变率", _visible_columns(SUMMARY_SHEETS[ENTITY_SELF]))
+        self.assertIn("三日均值ROI", frame.columns)
         self.assertIn("当日效率ROI", frame.columns)
         self.assertIn("当日效率ROI", frame.columns)
         self.assertIn("预测总效率ROI", frame.columns)
         self.assertIn("预测总效率ROI", frame.columns)
         visible = _visible_columns(SUMMARY_SHEETS[ENTITY_SELF])
         visible = _visible_columns(SUMMARY_SHEETS[ENTITY_SELF])
@@ -681,8 +714,9 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         for removed in ("动作", "动作原因", "阈值样本状态", "执行状态", "执行结果"):
         for removed in ("动作", "动作原因", "阈值样本状态", "执行状态", "执行结果"):
             self.assertNotIn(removed, visible)
             self.assertNotIn(removed, visible)
         self.assertEqual(
         self.assertEqual(
-            visible[visible.index("当日效率ROI") : visible.index("建议说明") + 1],
+            visible[visible.index("三日均值ROI") : visible.index("建议说明") + 1],
             [
             [
+                "三日均值ROI",
                 "当日效率ROI",
                 "当日效率ROI",
                 "预测总效率ROI",
                 "预测总效率ROI",
                 "关停线",
                 "关停线",
@@ -702,6 +736,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
         self.assertTrue(gzh_frame["扩量线(P80)"].isna().all())
         self.assertTrue(gzh_frame["扩量线(P80)"].isna().all())
         self.assertIn("是否低于三日关停线", frame.columns)
         self.assertIn("是否低于三日关停线", frame.columns)
         self.assertNotIn("关停线(P25)", frame.columns)
         self.assertNotIn("关停线(P25)", frame.columns)
+
         self.assertNotIn("整体实体等权P25关停线", frame.columns)
         self.assertNotIn("整体实体等权P25关停线", frame.columns)
         self.assertEqual(set(detail["dt"]), set(DATES))
         self.assertEqual(set(detail["dt"]), set(DATES))
         self.assertTrue((detail.groupby("广告id").size() == 3).all())
         self.assertTrue((detail.groupby("广告id").size() == 3).all())
@@ -742,7 +777,11 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             )
             )
             for sheet_name in expected:
             for sheet_name in expected:
                 self.assertEqual(workbook[sheet_name].freeze_panes, "H2")
                 self.assertEqual(workbook[sheet_name].freeze_panes, "H2")
-                self.assertEqual(len(workbook[sheet_name].conditional_formatting), 2)
+                expected_rules = 3 if sheet_name in SUMMARY_SHEETS.values() else 2
+                self.assertEqual(
+                    len(workbook[sheet_name].conditional_formatting),
+                    expected_rules,
+                )
             self.assertEqual(
             self.assertEqual(
                 workbook[SUMMARY_SHEETS[ENTITY_SELF_AD]].sheet_state, "hidden"
                 workbook[SUMMARY_SHEETS[ENTITY_SELF_AD]].sheet_state, "hidden"
             )
             )
@@ -755,7 +794,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
                 for rules in creative_sheet.conditional_formatting._cf_rules.values()
                 for rules in creative_sheet.conditional_formatting._cf_rules.values()
                 for rule in rules
                 for rule in rules
             ]
             ]
-            self.assertEqual(len(color_rules), 2)
+            self.assertEqual(len(color_rules), 3)
             for rule in color_rules:
             for rule in color_rules:
                 self.assertEqual(rule.type, "colorScale")
                 self.assertEqual(rule.type, "colorScale")
                 colors = [color.rgb[-6:] for color in rule.colorScale.color]
                 colors = [color.rgb[-6:] for color in rule.colorScale.color]
@@ -813,6 +852,12 @@ class RoiThreeDayRulesTest(unittest.TestCase):
                 creative_sheet.cell(2, creative_headers["广告age"]).number_format,
                 creative_sheet.cell(2, creative_headers["广告age"]).number_format,
                 "0",
                 "0",
             )
             )
+            self.assertEqual(
+                creative_sheet.cell(
+                    2, creative_headers["三日均值ROI"]
+                ).number_format,
+                "0.00",
+            )
             self.assertEqual(
             self.assertEqual(
                 creative_sheet.cell(
                 creative_sheet.cell(
                     2, creative_headers["当日效率ROI"]
                     2, creative_headers["当日效率ROI"]
@@ -856,7 +901,7 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             )
             )
             self.assertEqual(
             self.assertEqual(
                 run_summary.cell(
                 run_summary.cell(
-                    summary_rows["小程序单日高UV关停线"], 2
+                    summary_rows["小程序单日实际关停线"], 2
                 ).number_format,
                 ).number_format,
                 "0.00",
                 "0.00",
             )
             )
@@ -877,6 +922,29 @@ class RoiThreeDayRulesTest(unittest.TestCase):
             )
             )
             self.assertGreater(len(ad_sheet.data_validations.dataValidation), 0)
             self.assertGreater(len(ad_sheet.data_validations.dataValidation), 0)
 
 
+    def test_summary_latest_actual_roi_and_agency_score_use_latest_day(self):
+        rows = pd.DataFrame(
+            [
+                row(ENTITY_SELF, "score-window", dt, roi)
+                for dt, roi in zip(DATES, [1.0, 2.0, 3.0])
+            ]
+        )
+        rows.loc[rows["dt"].eq(DATES[-1]), "裂变效率收入"] = 100.0
+        ages = pd.DataFrame({"广告id": ["score-window"], "广告age": [10]})
+        _, _, summary = evaluate_rules(rows, DATES, ages)
+
+        internal = _summary_frame(summary, ENTITY_SELF).iloc[0]
+        agency = _agency_summary_frame(summary, ENTITY_SELF).iloc[0]
+        snapshot = summary.iloc[0]
+        latest_actual = float(snapshot[f"实际ROI_{DATES[-1]}"])
+        latest_predicted = float(snapshot[f"ROI_{DATES[-1]}"])
+
+        self.assertNotAlmostEqual(latest_actual, latest_predicted)
+        self.assertAlmostEqual(float(internal["三日均值ROI"]), float(snapshot["实际ROI"]))
+        self.assertAlmostEqual(float(internal["当日效率ROI"]), latest_actual)
+        self.assertAlmostEqual(float(internal["预测总效率ROI"]), float(snapshot["ROI"]))
+        self.assertAlmostEqual(float(agency["评分"]), latest_actual)
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()

+ 9 - 0
examples/auto_put_ad_mini/test_roi_control_policy.py

@@ -267,6 +267,15 @@ class RoiControlPolicyTest(unittest.TestCase):
             with self.assertRaisesRegex(ValueError, "requires ROI_APPLY_ENABLED"):
             with self.assertRaisesRegex(ValueError, "requires ROI_APPLY_ENABLED"):
                 RoiConfig.from_env()
                 RoiConfig.from_env()
 
 
+    def test_one_day_quantile_configuration_is_validated(self):
+        with patch.dict(
+            os.environ,
+            {"ROI_ONE_DAY_STOP_QUANTILE": "1"},
+            clear=False,
+        ):
+            with self.assertRaisesRegex(ValueError, "STOP_QUANTILE"):
+                RoiConfig.from_env()
+
     def test_roi_batch_commands_are_deterministic(self):
     def test_roi_batch_commands_are_deterministic(self):
         confirm = parse_command("确认 roi_20260725_north_star_roi_v1")
         confirm = parse_command("确认 roi_20260725_north_star_roi_v1")
         reject = parse_command("拒绝 roi_20260725_north_star_roi_v1")
         reject = parse_command("拒绝 roi_20260725_north_star_roi_v1")