فهرست منبع

fix(realtime-control): defer low CPM delivery to next day

刘立冬 6 روز پیش
والد
کامیت
b50aea6e4e

+ 3 - 3
examples/auto_put_ad_mini/docs/ad_automation_platform_architecture_plan_2026-07-25.md

@@ -67,9 +67,9 @@
 
 - `CPM > 250`:基础出价上调 25%,每天最多一次。
 - `190 <= CPM <= 250`:恢复基础出价和 CPM 策略暂停。
-- `CPM < 190`:恢复基础出价后暂停
-- 每天 20:00:恢复基础出价并执行当日收尾暂停
-- 次日 06:00:恢复实时策略暂停,再进入当日 CPM 判断
+- `CPM < 190`:恢复基础出价,将开始日期延后至次日,保持广告开启
+- 默认每天 21:00:只恢复基础出价,不主动暂停广告
+- 次日 06:00:由腾讯广告投放时段自动开始投放;实时 CPM 调控默认 12:00 开始
 
 ## 4. 飞书命令
 

+ 2 - 2
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -40,8 +40,8 @@ docker compose version
 首次部署增加:
 
 ```dotenv
-RTC_START_HOUR=6
-RTC_STOP_HOUR=20
+RTC_START_HOUR=12
+RTC_STOP_HOUR=21
 RTC_POLL_SECONDS=600
 RTC_HIGH_CPM=250
 RTC_LOW_CPM=190

+ 2 - 2
examples/tencent_realtime_control/.env.example

@@ -1,5 +1,5 @@
-RTC_START_HOUR=6
-RTC_STOP_HOUR=20
+RTC_START_HOUR=12
+RTC_STOP_HOUR=21
 RTC_POLL_SECONDS=600
 RTC_HIGH_CPM=250
 RTC_LOW_CPM=190

+ 9 - 5
examples/tencent_realtime_control/README.md

@@ -15,16 +15,20 @@
 
 ## 调控规则
 
-北京时间每天 `06:00-20:00` 每 10 分钟检查一次:
+北京时间默认每天 `12:00-21:00` 每 10 分钟检查一次。广告自身投放时段
+仍由 `time_series` 管理,在实时调控开始前按基础出价正常投放:
 
 - `CPM > 250`:基础出价上调 25%。
 - `190 <= CPM <= 250`:恢复基础出价并恢复策略暂停的广告。
-- `CPM < 190`:先恢复基础出价,再暂停广告。
+- `CPM < 190`:恢复基础出价,将广告开始日期延后至次日;保持广告开启,次日
+  进入 `time_series` 投放时段后由腾讯自动投放。
 - 同一广告每天最多上调一次;恢复基础价后,当天也不会再次上调。
-- `20:00`:先恢复基础出价,再暂停广告。
-- 次日 `06:00`:只恢复由本策略暂停的广告,不打开人工暂停广告。
+- 到达 `RTC_STOP_HOUR`:恢复基础出价,不主动暂停广告。广告由腾讯的
+  `time_series` 在非投放时段自动停止配量,次日进入投放时段后自动投放。
+- 截止时会恢复由低 CPM 策略暂停的广告,但不打开人工暂停广告。
 
-每个真实发生调价或关停动作的窗口会生成一张飞书在线表格并发送到调控群。
+每个真实发生调价、延后投放或状态修改的窗口会生成一张飞书在线表格
+并发送到调控群。
 群消息包含触发时间、数据分区、当前 CPM、决策、影响账户/广告数、动作分布和
 受影响广告当日累计消耗。表格包含日期、数据分区、当前 CPM、账户、广告 ID、
 广告名称、人群包、广告当日累计消耗/曝光/点击、基础出价、调整前出价、执行后

+ 20 - 4
examples/tencent_realtime_control/feishu_notifier.py

@@ -32,6 +32,8 @@ HEADERS = [
     "基础出价(元)",
     "调整前出价(元)",
     "当前出价(元)",
+    "调整前开始日期",
+    "调整后开始日期",
     "动作",
     "执行结果",
     "错误信息",
@@ -59,7 +61,12 @@ def _action_text(result: dict[str, Any], bid_up_ratio: Decimal) -> str:
             return "恢复基础出价"
         return "恢复投放"
     if decision == "PAUSE":
-        return "恢复基础出价并关停(CPM过低)" if bid_changed else "关停(CPM过低)"
+        target_date = str(result.get("target_begin_date") or "次日")
+        return (
+            f"恢复基础出价并延后至{target_date}(CPM过低)"
+            if bid_changed
+            else f"延后至{target_date}(CPM过低)"
+        )
     if decision == "CUTOFF":
         return "恢复基础出价并关停(投放截止)" if bid_changed else "关停(投放截止)"
     return decision
@@ -81,11 +88,12 @@ def notification_rows(
             continue
 
         is_bid_change = bool(result.get("bid_change_needed"))
+        is_begin_date_change = bool(result.get("begin_date_change_needed"))
         is_pause = (
             result.get("decision") in {"PAUSE", "CUTOFF"}
             and bool(result.get("status_change_needed"))
         )
-        if not is_bid_change and not is_pause:
+        if not is_bid_change and not is_begin_date_change and not is_pause:
             continue
 
         rows.append(
@@ -107,6 +115,8 @@ def notification_rows(
                     if result.get("status") != "failed"
                     else ""
                 ),
+                str(result.get("before_begin_date") or ""),
+                str(result.get("target_begin_date") or ""),
                 (
                     f"【模拟】{_action_text(result, bid_up_ratio)}"
                     if test_mode
@@ -154,7 +164,7 @@ def create_notification_xlsx(
         cell.alignment = Alignment(horizontal="center", vertical="center")
 
     widths = [
-        21, 18, 12, 14, 18, 30, 22, 16, 14, 14, 16, 16, 16, 30, 12, 55
+        21, 18, 12, 14, 18, 30, 22, 16, 14, 14, 16, 16, 16, 18, 18, 38, 12, 55
     ]
     for index, width in enumerate(widths, start=1):
         sheet.column_dimensions[chr(64 + index)].width = width
@@ -462,6 +472,7 @@ def build_notification_summary(
         if result.get("status") in expected_statuses
         and (
             result.get("bid_change_needed")
+            or result.get("begin_date_change_needed")
             or (
                 result.get("decision") in {"PAUSE", "CUTOFF"}
                 and result.get("status_change_needed")
@@ -490,10 +501,15 @@ def build_notification_summary(
                 for row in notified
             ),
             "关停": sum(
-                row.get("decision") in {"PAUSE", "CUTOFF"}
+                row.get("decision") == "CUTOFF"
                 and row.get("status_change_needed")
                 for row in notified
             ),
+            "延后至次日": sum(
+                row.get("decision") == "PAUSE"
+                and row.get("begin_date_change_needed")
+                for row in notified
+            ),
         },
     }
 

+ 4 - 4
examples/tencent_realtime_control/realtime_config.py

@@ -9,8 +9,8 @@ from decimal import Decimal
 
 @dataclass(frozen=True)
 class RealtimeControlConfig:
-    start_hour: int = 6
-    stop_hour: int = 20
+    start_hour: int = 12
+    stop_hour: int = 21
     poll_seconds: int = 600
     high_cpm: Decimal = Decimal("250")
     low_cpm: Decimal = Decimal("190")
@@ -22,8 +22,8 @@ class RealtimeControlConfig:
     @classmethod
     def from_env(cls) -> "RealtimeControlConfig":
         config = cls(
-            start_hour=int(os.getenv("RTC_START_HOUR", "6")),
-            stop_hour=int(os.getenv("RTC_STOP_HOUR", "20")),
+            start_hour=int(os.getenv("RTC_START_HOUR", "12")),
+            stop_hour=int(os.getenv("RTC_STOP_HOUR", "21")),
             poll_seconds=int(os.getenv("RTC_POLL_SECONDS", "600")),
             high_cpm=Decimal(os.getenv("RTC_HIGH_CPM", "250")),
             low_cpm=Decimal(os.getenv("RTC_LOW_CPM", "190")),

+ 54 - 20
examples/tencent_realtime_control/run_once.py

@@ -105,6 +105,7 @@ def target_for_ad(
     next_paused = False
     pause_reason = None
     limit_reached = False
+    defer_to_next_day = False
 
     if manual_suspend:
         if decision not in (DECISION_PAUSE, DECISION_CUTOFF):
@@ -118,6 +119,7 @@ def target_for_ad(
             "boost_limit_reached": False,
             "bid_change_needed": current_bid != base_bid,
             "status_change_needed": False,
+            "defer_to_next_day": False,
         }
 
     if decision == DECISION_BOOST:
@@ -134,10 +136,14 @@ def target_for_ad(
     elif decision == DECISION_RESTORE:
         if managed_suspend:
             target_status = ACTIVE_STATUS
-    elif decision in (DECISION_PAUSE, DECISION_CUTOFF):
+    elif decision == DECISION_PAUSE:
+        if managed_suspend:
+            target_status = ACTIVE_STATUS
+        defer_to_next_day = True
+    elif decision == DECISION_CUTOFF:
         target_status = SUSPEND_STATUS if current_status != SUSPEND_STATUS else None
         next_paused = True
-        pause_reason = "low_cpm" if decision == DECISION_PAUSE else "cutoff"
+        pause_reason = "cutoff"
     else:
         raise ValueError(f"Unsupported decision: {decision}")
 
@@ -151,6 +157,7 @@ def target_for_ad(
         "bid_change_needed": current_bid != target_bid,
         "status_change_needed": target_status is not None
         and target_status != current_status,
+        "defer_to_next_day": defer_to_next_day,
     }
 
 
@@ -302,7 +309,23 @@ def execute_inventory_action(
             if plan is None:
                 continue
 
-            needs_api = plan["bid_change_needed"] or plan["status_change_needed"]
+            before_begin_date = str(ad.get("begin_date") or "")
+            target_begin_date = None
+            begin_date_change_needed = False
+            if plan["defer_to_next_day"]:
+                next_day = (now.date() + timedelta(days=1)).isoformat()
+                target_begin_date = (
+                    before_begin_date
+                    if before_begin_date > next_day
+                    else next_day
+                )
+                begin_date_change_needed = before_begin_date != target_begin_date
+
+            needs_api = (
+                plan["bid_change_needed"]
+                or plan["status_change_needed"]
+                or begin_date_change_needed
+            )
             execution_status = "planned" if needs_api else "noop"
             error = None
             if apply and state is None:
@@ -313,8 +336,7 @@ def execute_inventory_action(
                     bid_field=bid_field,
                     base_bid_fen=base_bid,
                     boosted_date=None,
-                    paused_by_strategy=decision
-                    in (DECISION_PAUSE, DECISION_CUTOFF),
+                    paused_by_strategy=plan["paused_by_strategy"],
                     pause_reason=plan["pause_reason"],
                     last_action="REGISTER_BASE",
                     action_at=now,
@@ -338,19 +360,26 @@ def execute_inventory_action(
                 )
             if apply and needs_api:
                 try:
-                    tencent.update_ad(
-                        account_id,
-                        adgroup_id,
-                        bid_field=bid_field
-                        if plan["bid_change_needed"]
-                        else None,
-                        target_bid_fen=plan["target_bid_fen"]
-                        if plan["bid_change_needed"]
-                        else None,
-                        target_status=plan["target_status"]
-                        if plan["status_change_needed"]
-                        else None,
-                    )
+                    if begin_date_change_needed:
+                        tencent.update_ad_begin_dates(
+                            account_id,
+                            [adgroup_id],
+                            str(target_begin_date),
+                        )
+                    if plan["bid_change_needed"] or plan["status_change_needed"]:
+                        tencent.update_ad(
+                            account_id,
+                            adgroup_id,
+                            bid_field=bid_field
+                            if plan["bid_change_needed"]
+                            else None,
+                            target_bid_fen=plan["target_bid_fen"]
+                            if plan["bid_change_needed"]
+                            else None,
+                            target_status=plan["target_status"]
+                            if plan["status_change_needed"]
+                            else None,
+                        )
                     execution_status = "success"
                     api_updates += 1
                     time.sleep(0.15)
@@ -374,6 +403,9 @@ def execute_inventory_action(
                 "boost_limit_reached": plan["boost_limit_reached"],
                 "bid_change_needed": plan["bid_change_needed"],
                 "status_change_needed": plan["status_change_needed"],
+                "before_begin_date": before_begin_date,
+                "target_begin_date": target_begin_date,
+                "begin_date_change_needed": begin_date_change_needed,
                 "status": execution_status,
                 "error": error,
             }
@@ -433,6 +465,7 @@ def execute_inventory_action(
                 continue
             if not (
                 result.get("bid_change_needed")
+                or result.get("begin_date_change_needed")
                 or (
                     result.get("decision") in (DECISION_PAUSE, DECISION_CUTOFF)
                     and result.get("status_change_needed")
@@ -547,7 +580,7 @@ def run_cycle(
             execution = execute_inventory_action(
                 run_id=run_id,
                 now=now,
-                decision=DECISION_CUTOFF,
+                decision=DECISION_RESTORE,
                 observed_partition=None,
                 observed_cpm=None,
                 apply=apply,
@@ -589,7 +622,8 @@ def run_cycle(
                     last_inventory_refresh_at=now,
                     last_evaluated_at=now,
                 )
-            return payload
+            else:
+                return payload
 
         if cpm_override is not None:
             signal = HourlyCpm(