Explorar el Código

新增导出近7天小时级投放成本、收入、CPM、ROI的脚本

wangyunpeng hace 1 semana
padre
commit
bcd9008959

+ 224 - 0
examples/tencent_realtime_control/PROJECT_STRUCTURE.md

@@ -0,0 +1,224 @@
+# PROJECT_STRUCTURE — 腾讯广告实时调控模块
+
+> 目录:`examples/tencent_realtime_control/`
+> 定位:独立管理实时投放指标采集、CPM 状态判断和腾讯广告调控,不依赖广告创建或历史调价分析流程。
+
+本目录实际承载 **4 个相对独立的子系统**,共用同一套基础设施(MySQL / ODPS / 腾讯 API / 飞书):
+
+1. **实时 CPM 调控**(核心)— 按当天整体小时 CPM 阈值,自动提价/恢复/延后自动化广告
+2. **飞书运营命令控制** — 运营人员在指定飞书群 @机器人,用自然语言暂停/恢复/停止/查询广告
+3. **实时收入预测** — 预测当天最终收入,计算建议总成本(只读,不写腾讯接口)
+4. **日级 ROI 逐行审批**(消费方)— 代码位于 `examples/auto_put_ad_mini/roi_control/`,本目录只启动轮询服务
+
+---
+
+## 一、子系统总览
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│                    run_control_service.py                       │
+│                 (组合根 / Docker ad-control-service)            │
+│                                                                │
+│  ┌─────────────┐ ┌───────────────┐ ┌──────────────────────┐   │
+│  │ FeishuCommand│ │RoiSheetApproval│ │ revenue_forecast_service││
+│  │   Service    │ │    Service    │ │  (收入预测后台线程)    │   │
+│  └──────┬──────┘ └──────┬────────┘ └──────────┬───────────┘   │
+│         │              │                      │               │
+│         ▼              ▼                      ▼               │
+│  ┌────────────────────────────────────────────────────────┐   │
+│  │              run_scheduler.py (10分钟调度循环)          │   │
+│  │                        │                               │   │
+│  │                        ▼                               │   │
+│  │              run_once.py::run_cycle()                  │   │
+│  │              CPM 读取 → 决策 → 执行 → 通知             │   │
+│  └────────────────────────────────────────────────────────┘   │
+└────────────────────────────────────────────────────────────────┘
+            ▲                    ▲                    ▲
+            │                    │                    │
+      ODPS 小时CPM信号     腾讯 Marketing API      MySQL 状态/审计
+```
+
+---
+
+## 二、文件清单与职责
+
+### 1. 入口 / 服务装配
+
+| 文件 | 行数 | 职责 |
+|------|-----|------|
+| `run_control_service.py` | 85 | **生产常驻服务入口**。初始化 schema 后并行启动:飞书命令 WebSocket、ROI 表格审批轮询、收入预测后台线程、CPM 调度循环 `run_forever()`。写操作受 `RTC_APPLY_ENABLED` / `--apply` 控制 |
+| `run_scheduler.py` | 112 | CPM 调控的**常驻调度循环**。默认 12:00–21:00 每 600s 触发一次 `run_cycle()`,非投放时段休眠到次日 `RTC_START_HOUR` |
+| `run_once.py` | 798 | **单次幂等 CPM 调控周期**(也是调度循环的核心逻辑所在)。包含决策状态机、执行、写后回读、报告落盘。支持 `--apply/--at/--cpm-override/--force-refresh/--test-notification` |
+| `run_revenue_forecast.py` | 41 | 单次收入预测执行入口,默认受运行时间窗约束,`--ignore-runtime-window` 可越过 |
+| `init_db.py` | 18 | 初始化数据库 schema(读取 `schema.sql` + 运行迁移) |
+
+### 2. 实时 CPM 调控子系统
+
+| 文件 | 行数 | 职责 |
+|------|-----|------|
+| `realtime_config.py` | 61 | 调控参数:投放时段、CPM 高低阈值、提价比例、分区延迟上限、DB 锁名等(全部可环境变量覆盖) |
+| `odps_source.py` | 74 | **CPM 信号源**。从 `loghubods.advertiser_data_da_hour` 读当天整体小时 `真实cpm_总`,返回最新分区 |
+| `tencent_client.py` | 629 | 腾讯广告 API 读写封装。**写后回读校验**(默认 3 次×1s),读回不一致抛 `PostWriteVerificationError`。区分"未发出/被拒/结果未知"三类写失败 |
+| `storage.py` | 1312 | MySQL 状态与审计存储(全模块最大)。ad 状态、账户范围、每日状态、操作日志、运营命令、暂停状态、收入预测等全部落库逻辑 |
+| `feishu_notifier.py` | 607 | 飞书在线表格通知。真实调价/延后/状态变更的窗口生成 xlsx + 发群;dry-run 与无动作不通知 |
+| `fetch_daily_hourly_cpm.py` | 60 | 数据检查工具:查看指定日期分时 CPM,落 CSV 并打印 |
+
+**决策逻辑(在 `run_once.py`)**:
+
+```python
+decide(cpm, low, high):
+    cpm > high(250)   → BOOST   # 基础出价 ×1.05 上调,每天至多一次
+    low(190) ≤ cpm ≤ high → RESTORE  # 恢复基础出价 + 恢复策略暂停的广告
+    cpm < low(190)    → PAUSE   # 恢复基础价,begin_date 延后到次日(不 SUSPEND)
+```
+
+### 3. 飞书运营命令子系统
+
+| 文件 | 行数 | 职责 |
+|------|-----|------|
+| `feishu_command_service.py` | 395 | 飞书 WebSocket 入口。校验群/发送人/@机器人后串行处理消息;多轮补问草稿、命令预览、确认/取消 |
+| `operator_commands.py` | 198 | **确定性命令解析**。`ParsedCommand` / `CommandIntent` 数据类,暂停/停止/恢复/状态/今日消耗等动作的规则解析 |
+| `command_intent_parser.py` | 218 | **受限 NLU**。标准命令走确定性解析;`RTC_NL_COMMAND_ENABLED=1` 时模型只返回动作+范围 JSON,不可读库/调接口 |
+| `operator_control.py` | 899 | 命令**预览与执行**。预览冻结广告快照 + 冲突检测;确认后按账户/广告执行暂停/恢复/停止,含写后回读、状态机流转 |
+| `today_spend_query.py` | 79 | 只读的今日消耗汇总查询(全部/自动化/指定账户),逐账户调腾讯报表聚合 |
+
+**命令生命周期**:`解析 → 预览(冻结明细) → 10分钟内确认 → 执行 → 回读校验 → 审计落库`
+
+### 4. 实时收入预测子系统
+
+| 文件 | 行数 | 职责 |
+|------|-----|------|
+| `revenue_forecast.py` | 57 | 领域数据类:`RevenueObservation`、`RevenueTrendWindow`、`RevenueForecast` |
+| `revenue_forecast_config.py` | 115 | 预测配置:运行时段、15分钟窗口数、目标成本比例、参数版本、DB 锁名 |
+| `revenue_forecast_source.py` | 250 | ODPS 取数:`ads_ad_own_package_detail_15min` 15分钟收入序列、`opengid_base_data` 渠道成本、当日快照 |
+| `revenue_speed_forecast.py` | 243 | **加权速度预测算法**。`build_speed_samples` → `aggregate_speed_parameters`(发布 P10/P50/P90)→ `calculate_speed_forecast` |
+| `revenue_forecast_repository.py` | 370 | 预测结果/观测/速度样本/已发布参数的 MySQL 持久化;参数版本发布后不可覆盖 |
+| `revenue_forecast_job.py` | 212 | 单轮预测周期:取数 → 质量校验(VALID/STALE/CORRECTED) → 载入参数 → 计算 → 落库 |
+| `revenue_forecast_service.py` | 77 | 后台调度线程(默认关闭,`REVENUE_FORECAST_ENABLED=1` 启用) |
+| `build_revenue_speed_parameters.py` | 118 | 从完整历史日构建并**发布速度参数版本**(`--start-date/--end-date/--parameter-version`) |
+| `backtest_revenue_speed_forecast.py` | 284 | 严格走步回测:每个预测日只用此前日期训练,只读 ODPS,不写 MySQL |
+
+### 5. 实验 / 测试 / 配置
+
+| 文件 | 行数 | 职责 |
+|------|-----|------|
+| `adjust_bid_experiment_20260729.py` | 284 | 一次性八广告分组调价实验(2026-07-29),默认只预览,带 `bid_hold` 释放与账户范围注册 |
+| `test_feishu_natural_commands.py` | 439 | 飞书命令链路单测:确定性解析、NLU 兜底、预览、调度器唤醒、暂停执行 |
+| `test_revenue_forecast.py` | 158 | 收入预测算法单测:速度样本构建、参数聚合、预测计算、异常分支 |
+| `schema.sql` | 420 | 全部表结构(见下) |
+| `requirements.txt` | 7 | pandas / openpyxl / pyodps / pymysql / python-dotenv / requests |
+| `.env.example` | 76 | 环境变量样例(本地开发兼容;生产以仓库根 `runtime.env.example` 为准) |
+| `__init__.py` | 1 | 模块标记 |
+
+### 6. 外部依赖(不在本目录)
+
+| 路径 | 用途 |
+|------|------|
+| `examples/auto_put_ad_mini/roi_control/` | 日级 ROI 指标计算与审批表发布,`run_control_service.py` 启动其 `RoiSheetApprovalService` |
+| `agent.tools.builtin.feishu.feishu_client` | 框架内置飞书客户端(WebSocket / 发消息) |
+
+---
+
+## 三、数据库表(`schema.sql`)
+
+| 表 | 用途 |
+|----|------|
+| `realtime_control_daily_state` | 每日 CPM 调控状态(最后分区/决策/刷新时间) |
+| `realtime_control_ad_state` | **广告基准与状态**:base_bid_fen、boosted_date、bid_hold、paused_by_strategy、operator_pause_* |
+| `realtime_control_account_scope` | 实时调控显式账户范围(control_mode:FULL / PAUSE_ONLY) |
+| `realtime_control_action_log` | 每次真实动作的审计日志 |
+| `operator_command` / `operator_command_item` / `operator_command_draft` | 飞书运营命令、冻结明细、多轮草稿 |
+| `roi_metric_run` / `roi_fission_parameter_*` / `roi_entity_snapshot` / `roi_action_item` / `roi_agency_delivery` | 日级 ROI 指标运行与审批动作(ROI 子系统表,由 `auto_put_ad_mini` 写入) |
+| `revenue_forecast_observation` / `result` / `speed_sample` / `speed_parameter` | 收入预测的原始快照、版本化结果、历史速度样本、已发布参数 |
+
+`storage.py::initialize_schema()` 会在建表后额外执行 `realtime_control_ad_state` 的增量列迁移。
+
+---
+
+## 四、关键数据流
+
+### CPM 调控(每 10 分钟)
+
+```
+ODPS 小时CPM (odps_source)
+   │ fetch_latest_cpm()
+   ▼
+run_cycle() ── 校验分区延迟 ≤2h / 时段 12–21 点
+   │ decide() → BOOST / RESTORE / PAUSE / CUTOFF / WAIT_DATA / OFF_HOURS
+   ▼
+execute_inventory_action()
+   ├─ load_ad_states() 取每账户广告状态(MySQL)
+   ├─ tencent.get_ads() 拉实时广告
+   ├─ target_for_ad() 计算目标出价/状态/begin_date
+   └─ tencent.update_ad()/update_ad_begin_dates() 写腾讯(含回读校验)
+   ▼
+feishu_notifier.send_action_notification() ── 真实变更才发飞书在线表格
+   ▼
+upsert_ad_state() / insert_action_log() ── 状态与审计落库
+```
+
+### 飞书命令(消息触发)
+
+```
+飞书群 @机器人
+   ▼ FeishuCommandService._authorized() 校验群/人/@
+   ▼ understand():确定性解析 → (可选)LLM 兜底
+   ▼ preview_write_command():冻结广告快照 + 冲突检测 → 回复预览
+   ▼ 用户「确认 cmd_xxx」(10分钟内)
+   ▼ execute_confirmed_command():锁内执行 → 写后回读 → 审计
+```
+
+---
+
+## 五、配置要点(`.env.example` / `realtime_config.py`)
+
+| 变量 | 默认 | 含义 |
+|------|------|------|
+| `RTC_START_HOUR` / `RTC_STOP_HOUR` | 12 / 21 | CPM 调控投放时段 |
+| `RTC_POLL_SECONDS` | 600 | 调控轮询间隔 |
+| `RTC_HIGH_CPM` / `RTC_LOW_CPM` | 250 / 190 | 提价 / 延后阈值 |
+| `RTC_BID_UP_RATIO` | 1.05 | 提价比例 |
+| `RTC_APPLY_ENABLED` | 0 | **总闸**:所有入口默认 dry-run,为 1 才写腾讯 |
+| `RTC_COMMAND_ENABLED` / `RTC_COMMAND_CHAT_ID` / `RTC_COMMAND_ALLOWED_OPEN_IDS` | - | 飞书命令开关与白名单 |
+| `RTC_NL_COMMAND_ENABLED` | 0 | 启用 LLM 兜底解析 |
+| `REVENUE_FORECAST_ENABLED` | 0 | 收入预测后台服务开关 |
+| `REVENUE_SPEED_PARAMETER_VERSION` | revenue_speed_params_v1 | 已发布速度参数版本(只读) |
+
+> ⚠️ 所有执行入口默认 **dry-run**,只有 `--apply` 或 `RTC_APPLY_ENABLED=1` 才调用腾讯写接口;`--at` / `--cpm-override` 仅限 dry-run(`run_once.py` 会校验)。
+
+---
+
+## 六、常用运行命令
+
+```bash
+# 初始化数据库
+.venv/bin/python examples/tencent_realtime_control/init_db.py
+
+# 单次 CPM 调控(dry-run)
+.venv/bin/python examples/tencent_realtime_control/run_once.py
+# 验证指定 CPM 分支
+.venv/bin/python examples/tencent_realtime_control/run_once.py --at 2026-07-24T15:00:00 --cpm-override 260
+# 真实执行
+.venv/bin/python examples/tencent_realtime_control/run_once.py --apply
+
+# 生产常驻服务(含飞书命令 + ROI 审批 + 收入预测 + CPM 循环)
+RTC_APPLY_ENABLED=0 .venv/bin/python examples/tencent_realtime_control/run_control_service.py
+
+# 收入预测:发布参数 / 单次执行 / 走步回测
+.venv/bin/python examples/tencent_realtime_control/build_revenue_speed_parameters.py --start-date 20260727 --end-date 20260805 --parameter-version revenue_speed_params_v1
+.venv/bin/python examples/tencent_realtime_control/run_revenue_forecast.py
+.venv/bin/python examples/tencent_realtime_control/backtest_revenue_speed_forecast.py --history-start 20260727 --start-date 20260728 --end-date 20260805
+
+# 查看当天分时 CPM
+.venv/bin/python examples/tencent_realtime_control/fetch_daily_hourly_cpm.py
+```
+
+---
+
+## 七、扩展指引
+
+- **调整 CPM 阈值/时段** → 改 `realtime_config.py` 或环境变量,无需动业务代码
+- **新增决策分支** → 在 `run_once.py::decide()` 与 `target_for_ad()` 中扩展,并补充 `feishu_notifier._action_text()` 的动作文案
+- **新增飞书命令** → `operator_commands.py` 定义动作常量与解析 → `operator_control.py` 实现预览/执行 → `feishu_command_service.py` 接入消息分发
+- **训练新预测参数** → 用 `build_revenue_speed_parameters.py` 发布新版本号(发布后不可覆盖)
+- **任何涉及腾讯写操作的新功能** → 复用 `tencent_client.py` 的写后回读校验与失败分类,不要裸调 API

+ 29 - 0
examples/tencent_realtime_control/README.md

@@ -171,6 +171,35 @@ ODPS 小时分区可能延迟。当天尚未出现 `06` 点及之后的分区时
   --date 20260724
 ```
 
+导出近 7 天小时级 ROI 统计,并导入飞书在线表格;脚本只读取 ODPS 和上传表格,
+不会调用腾讯写接口:
+
+```bash
+conda run -n agent python \
+  examples/tencent_realtime_control/export_historical_hourly_roi.py
+```
+
+输出会包含本地 xlsx 路径和 `sheet_url=...`。默认统计截至当天的 7 个自然日,
+每天包含 `06:00-22:00` 共 17 个小时。每行同时包含当前小时值,以及当天
+`00:00` 至当前小时的累计总收入、累计总投放成本、累计小程序成本和对应收入比。
+上传后的飞书链接默认设置为获得链接者可编辑。也可以指定历史区间:
+
+```bash
+conda run -n agent python \
+  examples/tencent_realtime_control/export_historical_hourly_roi.py \
+  --end-date 20260811 --days 7
+```
+
+统计时段可通过 `--start-hour` 和 `--end-hour` 调整,结束小时包含在结果中。
+
+如只想生成本地文件用于校验,不上传飞书:
+
+```bash
+conda run -n agent python \
+  examples/tencent_realtime_control/export_historical_hourly_roi.py \
+  --end-date 20260811 --days 7 --skip-upload
+```
+
 ## 单次执行
 
 默认 dry-run,不修改腾讯:

+ 516 - 0
examples/tencent_realtime_control/export_historical_hourly_roi.py

@@ -0,0 +1,516 @@
+#!/usr/bin/env python
+"""Export historical hourly spend, revenue, CPM and ROI to a Feishu sheet."""
+
+from __future__ import annotations
+
+import argparse
+import math
+import re
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Font, PatternFill
+from openpyxl.utils import get_column_letter
+
+if TYPE_CHECKING:
+    from odps import ODPS
+
+
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+ROOT = Path(__file__).resolve().parent
+MINIAPP_CHANNEL = "小程序投流-稳定"
+REVENUE_TABLE = "ads_ad_own_package_detail_15min"
+COST_TABLE = "opengid_base_data"
+DEFAULT_START_HOUR = 6
+DEFAULT_END_HOUR = 22
+
+REPORT_HEADERS = [
+    "日期",
+    "小时",
+    "总投放成本",
+    "小程序投流成本",
+    "商业化收入",
+    "CPM",
+    "收入/总投放",
+    "收入/小程序成本",
+    "当日累计总收入",
+    "当日累计总投放成本",
+    "当日累计小程序投流成本",
+    "当日累计收入/总投放",
+    "当日累计收入/小程序成本",
+    "总去重UV",
+    "总单用户成本",
+    "小程序去重UV",
+    "小程序单用户成本",
+    "收入15分钟窗口数",
+    "最新收入分区",
+]
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--end-date",
+        default=datetime.now(SHANGHAI).strftime("%Y%m%d"),
+        help="Inclusive end date in YYYYMMDD format; defaults to today.",
+    )
+    parser.add_argument(
+        "--days",
+        type=int,
+        default=7,
+        help="Number of calendar days to export, inclusive of --end-date.",
+    )
+    parser.add_argument(
+        "--miniapp-channel",
+        default=MINIAPP_CHANNEL,
+        help="Channel counted as miniapp delivery cost.",
+    )
+    parser.add_argument(
+        "--start-hour",
+        type=int,
+        default=DEFAULT_START_HOUR,
+        help="First hourly bucket to export; defaults to 6.",
+    )
+    parser.add_argument(
+        "--end-hour",
+        type=int,
+        default=DEFAULT_END_HOUR,
+        help="Last hourly bucket to export, inclusive; defaults to 22.",
+    )
+    parser.add_argument(
+        "--output",
+        type=Path,
+        help="Local xlsx output path; defaults to outputs/historical_hourly_roi_*.xlsx.",
+    )
+    parser.add_argument(
+        "--skip-upload",
+        action="store_true",
+        help="Only generate the local xlsx; do not import it to Feishu.",
+    )
+    return parser.parse_args()
+
+
+def _parse_date(value: str) -> date:
+    if not re.fullmatch(r"\d{8}", value):
+        raise ValueError("date must use YYYYMMDD format")
+    return datetime.strptime(value, "%Y%m%d").date()
+
+
+def _date_values(start_date: date, end_date: date) -> list[date]:
+    days = (end_date - start_date).days
+    return [start_date + timedelta(days=offset) for offset in range(days + 1)]
+
+
+def fetch_revenue_rows(
+    client: "ODPS",
+    *,
+    start_date: date,
+    end_date: date,
+) -> pd.DataFrame:
+    sql = f"""
+SELECT
+  dt,
+  report_date,
+  MIN(NVL(daily_exposure_cnt, 0)) AS daily_exposure_cnt,
+  MIN(NVL(overall_cpm, 0)) AS overall_cpm,
+  MIN(NVL(package_cost_times_today, 0)) AS package_cost_times_today
+FROM loghubods.{REVENUE_TABLE}
+WHERE dt >= '{start_date:%Y%m%d}000000'
+  AND dt <= '{end_date:%Y%m%d}235959'
+  AND data_type = 'today'
+  AND report_date IS NOT NULL
+GROUP BY dt, report_date
+ORDER BY report_date
+""".strip()
+    instance = client.execute_sql(sql, hints={"odps.sql.submit.mode": "script"})
+    with instance.open_reader(tunnel=True) as reader:
+        return reader.to_pandas()
+
+
+def fetch_cost_rows(
+    client: "ODPS",
+    *,
+    start_date: date,
+    end_date: date,
+    miniapp_channel: str,
+) -> pd.DataFrame:
+    escaped_channel = miniapp_channel.replace("'", "''")
+    sql = f"""
+SELECT
+  dt AS data_date,
+  CAST(`点击小时` AS BIGINT) AS hour_of_day,
+  COUNT(DISTINCT mid) AS total_uv,
+  SUM(NVL(`成本`, 0)) AS total_cost,
+  CASE
+    WHEN COUNT(DISTINCT mid) > 0
+    THEN SUM(NVL(`成本`, 0)) / COUNT(DISTINCT mid)
+    ELSE 0
+  END AS total_unit_user_cost,
+  COUNT(DISTINCT CASE WHEN channel = '{escaped_channel}' THEN mid ELSE NULL END)
+    AS miniapp_uv,
+  SUM(CASE WHEN channel = '{escaped_channel}' THEN NVL(`成本`, 0) ELSE 0 END)
+    AS miniapp_cost,
+  CASE
+    WHEN COUNT(DISTINCT CASE WHEN channel = '{escaped_channel}' THEN mid ELSE NULL END) > 0
+    THEN SUM(CASE WHEN channel = '{escaped_channel}' THEN NVL(`成本`, 0) ELSE 0 END)
+      / COUNT(DISTINCT CASE WHEN channel = '{escaped_channel}' THEN mid ELSE NULL END)
+    ELSE 0
+  END AS miniapp_unit_user_cost
+FROM loghubods.{COST_TABLE}
+WHERE dt >= '{start_date:%Y%m%d}'
+  AND dt <= '{end_date:%Y%m%d}'
+  AND usersharedepth = '0'
+  AND videoid IS NOT NULL
+  AND NVL(hotsencetype, '') <> '1167'
+GROUP BY dt, CAST(`点击小时` AS BIGINT)
+ORDER BY dt, hour_of_day
+""".strip()
+    instance = client.execute_sql(sql, hints={"odps.sql.submit.mode": "script"})
+    with instance.open_reader(tunnel=True) as reader:
+        return reader.to_pandas()
+
+
+def hourly_revenue(revenue_rows: pd.DataFrame) -> pd.DataFrame:
+    columns = [
+        "data_date",
+        "hour_of_day",
+        "commercial_revenue",
+        "cpm",
+        "revenue_window_count",
+        "latest_revenue_partition",
+    ]
+    if revenue_rows.empty:
+        return pd.DataFrame(columns=columns)
+
+    rows = revenue_rows.copy()
+    rows["window_start"] = pd.to_datetime(
+        rows["report_date"].astype(str),
+        format="%Y%m%d%H%M%S",
+        errors="coerce",
+    )
+    rows = rows.dropna(subset=["window_start"])
+    rows["data_date"] = rows["window_start"].dt.strftime("%Y%m%d")
+    rows["hour_of_day"] = rows["window_start"].dt.hour.astype("int64")
+    rows["commercial_revenue"] = pd.to_numeric(
+        rows["package_cost_times_today"],
+        errors="coerce",
+    ).fillna(0.0)
+    rows["daily_exposure_cnt"] = pd.to_numeric(
+        rows["daily_exposure_cnt"],
+        errors="coerce",
+    ).fillna(0.0)
+    rows["overall_cpm"] = pd.to_numeric(rows["overall_cpm"], errors="coerce")
+    rows["weighted_cpm"] = rows["overall_cpm"].fillna(0.0) * rows[
+        "daily_exposure_cnt"
+    ]
+
+    grouped = (
+        rows.groupby(["data_date", "hour_of_day"], as_index=False)
+        .agg(
+            commercial_revenue=("commercial_revenue", "sum"),
+            exposure=("daily_exposure_cnt", "sum"),
+            weighted_cpm=("weighted_cpm", "sum"),
+            avg_cpm=("overall_cpm", "mean"),
+            revenue_window_count=("report_date", "count"),
+            latest_revenue_partition=("dt", "max"),
+        )
+        .sort_values(["data_date", "hour_of_day"])
+    )
+    grouped["cpm"] = grouped.apply(
+        lambda row: (
+            row["weighted_cpm"] / row["exposure"]
+            if row["exposure"] and not math.isnan(row["exposure"])
+            else row["avg_cpm"]
+        ),
+        axis=1,
+    )
+    return grouped[columns]
+
+
+def hourly_cost(cost_rows: pd.DataFrame) -> pd.DataFrame:
+    columns = [
+        "data_date",
+        "hour_of_day",
+        "total_cost",
+        "miniapp_cost",
+        "total_uv",
+        "total_unit_user_cost",
+        "miniapp_uv",
+        "miniapp_unit_user_cost",
+    ]
+    if cost_rows.empty:
+        return pd.DataFrame(columns=columns)
+
+    rows = cost_rows.copy()
+    rows["data_date"] = rows["data_date"].astype(str)
+    rows["hour_of_day"] = pd.to_numeric(rows["hour_of_day"], errors="coerce")
+    rows = rows.dropna(subset=["hour_of_day"])
+    rows["hour_of_day"] = rows["hour_of_day"].astype("int64")
+    for column in columns[2:]:
+        rows[column] = pd.to_numeric(rows[column], errors="coerce").fillna(0.0)
+    return rows[columns].sort_values(["data_date", "hour_of_day"])
+
+
+def build_hourly_report(
+    *,
+    revenue_rows: pd.DataFrame,
+    cost_rows: pd.DataFrame,
+    start_date: date,
+    end_date: date,
+    start_hour: int = DEFAULT_START_HOUR,
+    end_hour: int = DEFAULT_END_HOUR,
+) -> pd.DataFrame:
+    if not 0 <= start_hour <= end_hour <= 23:
+        raise ValueError("hours must satisfy 0 <= start_hour <= end_hour <= 23")
+    # Build the full day first so cumulative values at 06:00 include 00:00-05:00.
+    grid = pd.DataFrame(
+        [
+            {"data_date": f"{item:%Y%m%d}", "hour_of_day": hour}
+            for item in _date_values(start_date, end_date)
+            for hour in range(24)
+        ]
+    )
+    report = grid.merge(
+        hourly_cost(cost_rows),
+        on=["data_date", "hour_of_day"],
+        how="left",
+    ).merge(
+        hourly_revenue(revenue_rows),
+        on=["data_date", "hour_of_day"],
+        how="left",
+    )
+
+    numeric_defaults = {
+        "total_cost": 0.0,
+        "miniapp_cost": 0.0,
+        "commercial_revenue": 0.0,
+        "total_uv": 0.0,
+        "total_unit_user_cost": 0.0,
+        "miniapp_uv": 0.0,
+        "miniapp_unit_user_cost": 0.0,
+        "revenue_window_count": 0.0,
+    }
+    for column, default in numeric_defaults.items():
+        report[column] = pd.to_numeric(report[column], errors="coerce").fillna(default)
+
+    report["收入/总投放"] = report.apply(
+        lambda row: (
+            row["commercial_revenue"] / row["total_cost"]
+            if row["total_cost"] > 0
+            else None
+        ),
+        axis=1,
+    )
+    report["收入/小程序成本"] = report.apply(
+        lambda row: (
+            row["commercial_revenue"] / row["miniapp_cost"]
+            if row["miniapp_cost"] > 0
+            else None
+        ),
+        axis=1,
+    )
+    report = report.sort_values(["data_date", "hour_of_day"]).reset_index(drop=True)
+    report["当日累计总收入"] = report.groupby("data_date")[
+        "commercial_revenue"
+    ].cumsum()
+    report["当日累计总投放成本"] = report.groupby("data_date")[
+        "total_cost"
+    ].cumsum()
+    report["当日累计小程序投流成本"] = report.groupby("data_date")[
+        "miniapp_cost"
+    ].cumsum()
+    report["当日累计收入/总投放"] = report.apply(
+        lambda row: (
+            row["当日累计总收入"] / row["当日累计总投放成本"]
+            if row["当日累计总投放成本"] > 0
+            else None
+        ),
+        axis=1,
+    )
+    report["当日累计收入/小程序成本"] = report.apply(
+        lambda row: (
+            row["当日累计总收入"] / row["当日累计小程序投流成本"]
+            if row["当日累计小程序投流成本"] > 0
+            else None
+        ),
+        axis=1,
+    )
+    report["日期"] = pd.to_datetime(report["data_date"], format="%Y%m%d").dt.strftime(
+        "%Y-%m-%d"
+    )
+    report["小时"] = report["hour_of_day"].map(lambda value: f"{int(value):02d}:00")
+    report = report.rename(
+        columns={
+            "total_cost": "总投放成本",
+            "miniapp_cost": "小程序投流成本",
+            "commercial_revenue": "商业化收入",
+            "cpm": "CPM",
+            "total_uv": "总去重UV",
+            "total_unit_user_cost": "总单用户成本",
+            "miniapp_uv": "小程序去重UV",
+            "miniapp_unit_user_cost": "小程序单用户成本",
+            "revenue_window_count": "收入15分钟窗口数",
+            "latest_revenue_partition": "最新收入分区",
+        }
+    )
+    return report[
+        report["hour_of_day"].between(start_hour, end_hour)
+    ][REPORT_HEADERS]
+
+
+def _clean_cell(value: Any) -> Any:
+    if pd.isna(value):
+        return ""
+    if isinstance(value, float) and math.isfinite(value):
+        return float(value)
+    return value
+
+
+def create_workbook(
+    *,
+    report: pd.DataFrame,
+    start_date: date,
+    end_date: date,
+    miniapp_channel: str,
+    output_path: Path,
+    start_hour: int = DEFAULT_START_HOUR,
+    end_hour: int = DEFAULT_END_HOUR,
+) -> Path:
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    workbook = Workbook()
+    sheet = workbook.active
+    sheet.title = "历史小时ROI"
+    sheet.append(REPORT_HEADERS)
+    for row in report.itertuples(index=False):
+        sheet.append([_clean_cell(value) for value in row])
+
+    header_fill = PatternFill("solid", fgColor="1F4E78")
+    for cell in sheet[1]:
+        cell.fill = header_fill
+        cell.font = Font(color="FFFFFF", bold=True)
+        cell.alignment = Alignment(horizontal="center", vertical="center")
+
+    widths = [
+        13, 9, 14, 16, 14, 10, 14, 17, 16, 18, 22, 20, 24,
+        11, 14, 13, 16, 17, 18,
+    ]
+    for index, width in enumerate(widths, start=1):
+        sheet.column_dimensions[get_column_letter(index)].width = width
+    for row in sheet.iter_rows(min_row=2):
+        for cell in row:
+            cell.alignment = Alignment(horizontal="center", vertical="center")
+    for column in ("C", "D", "E", "F", "I", "J", "K", "O", "Q"):
+        for cell in sheet[column][1:]:
+            cell.number_format = "0.00"
+    for column in ("G", "H", "L", "M"):
+        for cell in sheet[column][1:]:
+            cell.number_format = "0.0000"
+    for column in ("N", "P", "R"):
+        for cell in sheet[column][1:]:
+            cell.number_format = "0"
+    sheet.freeze_panes = "A2"
+    sheet.auto_filter.ref = sheet.dimensions
+
+    summary = workbook.create_sheet("运行摘要")
+    summary_rows = [
+        ("生成时间", datetime.now(SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")),
+        ("统计开始日期", start_date.strftime("%Y-%m-%d")),
+        ("统计结束日期", end_date.strftime("%Y-%m-%d")),
+        ("每日统计时段", f"{start_hour:02d}:00-{end_hour:02d}:59"),
+        ("统计小时数", len(report)),
+        ("小程序成本渠道", miniapp_channel),
+        ("总投放成本", float(report["总投放成本"].sum())),
+        ("小程序投流成本", float(report["小程序投流成本"].sum())),
+        ("商业化收入", float(report["商业化收入"].sum())),
+    ]
+    summary.append(["字段", "值"])
+    for row in summary_rows:
+        summary.append(list(row))
+    for cell in summary[1]:
+        cell.fill = header_fill
+        cell.font = Font(color="FFFFFF", bold=True)
+    summary.column_dimensions["A"].width = 22
+    summary.column_dimensions["B"].width = 28
+    for cell in summary["B"][1:]:
+        if isinstance(cell.value, float):
+            cell.number_format = "0.00"
+
+    workbook.save(output_path)
+    return output_path
+
+
+def main() -> None:
+    args = parse_args()
+    if args.days <= 0:
+        raise ValueError("--days must be positive")
+    if not 0 <= args.start_hour <= args.end_hour <= 23:
+        raise ValueError(
+            "hours must satisfy 0 <= --start-hour <= --end-hour <= 23"
+        )
+
+    end_date = _parse_date(args.end_date)
+    start_date = end_date - timedelta(days=args.days - 1)
+
+    from dotenv import load_dotenv
+    from feishu_notifier import FeishuNotifier
+    from odps_source import build_odps_client
+
+    load_dotenv(ROOT.parent / "auto_put_ad_mini" / ".env", override=False)
+    load_dotenv(Path.cwd() / ".env", override=False)
+
+    client = build_odps_client()
+    revenue_rows = fetch_revenue_rows(
+        client,
+        start_date=start_date,
+        end_date=end_date,
+    )
+    cost_rows = fetch_cost_rows(
+        client,
+        start_date=start_date,
+        end_date=end_date,
+        miniapp_channel=args.miniapp_channel,
+    )
+    report = build_hourly_report(
+        revenue_rows=revenue_rows,
+        cost_rows=cost_rows,
+        start_date=start_date,
+        end_date=end_date,
+        start_hour=args.start_hour,
+        end_hour=args.end_hour,
+    )
+
+    output_path = args.output or (
+        ROOT
+        / "outputs"
+        / (
+            "historical_hourly_roi_"
+            f"{start_date:%Y%m%d}_{end_date:%Y%m%d}.xlsx"
+        )
+    )
+    path = create_workbook(
+        report=report,
+        start_date=start_date,
+        end_date=end_date,
+        miniapp_channel=args.miniapp_channel,
+        output_path=output_path,
+        start_hour=args.start_hour,
+        end_hour=args.end_hour,
+    )
+
+    print(f"rows={len(report)}")
+    print(f"date_range={start_date:%Y%m%d}-{end_date:%Y%m%d}")
+    print(f"output={path}")
+    if args.skip_upload:
+        print("sheet_url=SKIPPED")
+        return
+
+    url = FeishuNotifier().import_spreadsheet(path)
+    print(f"sheet_url={url}")
+
+
+if __name__ == "__main__":
+    main()

+ 59 - 2
examples/tencent_realtime_control/feishu_notifier.py

@@ -206,6 +206,20 @@ class FeishuNotifier:
                 f"Missing Feishu notification configuration: {', '.join(missing)}"
             )
 
+    def _require_drive_config(self) -> None:
+        missing = [
+            name
+            for name, value in (
+                ("FEISHU_APP_ID", self.app_id),
+                ("FEISHU_APP_SECRET", self.app_secret),
+            )
+            if not value
+        ]
+        if missing:
+            raise RuntimeError(
+                f"Missing Feishu drive configuration: {', '.join(missing)}"
+            )
+
     def _tenant_token(self) -> str:
         response = self.session.post(
             f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
@@ -290,6 +304,31 @@ class FeishuNotifier:
 
     def _set_read_permission(
         self, token: str, sheet_token: str, file_type: str
+    ) -> None:
+        self._set_link_permission(
+            token,
+            sheet_token,
+            file_type,
+            link_share_entity="anyone_readable",
+        )
+
+    def _set_edit_permission(
+        self, token: str, sheet_token: str, file_type: str
+    ) -> None:
+        self._set_link_permission(
+            token,
+            sheet_token,
+            file_type,
+            link_share_entity="anyone_editable",
+        )
+
+    def _set_link_permission(
+        self,
+        token: str,
+        sheet_token: str,
+        file_type: str,
+        *,
+        link_share_entity: str,
     ) -> None:
         response = self.session.patch(
             f"{FEISHU_BASE_URL}/drive/v1/permissions/{sheet_token}/public",
@@ -297,14 +336,14 @@ class FeishuNotifier:
             params={"type": file_type},
             json={
                 "external_access_entity": "open",
-                "link_share_entity": "anyone_readable",
+                "link_share_entity": link_share_entity,
             },
             timeout=self.timeout,
         )
         response.raise_for_status()
         payload = response.json()
         if payload.get("code") != 0:
-            logger.warning("Feishu permission update failed: %s", payload)
+            raise RuntimeError(f"Feishu permission update failed: {payload}")
 
     def _send_card(
         self,
@@ -455,6 +494,24 @@ class FeishuNotifier:
         )
         return url
 
+    def import_spreadsheet(self, path: Path, *, readonly_link: bool = False) -> str:
+        """Upload a local xlsx and return the Feishu online spreadsheet URL."""
+        self._require_drive_config()
+        token = self._tenant_token()
+        file_token = self._upload(token, path)
+        result = self._import_sheet(token, file_token, path)
+        url = str(result.get("url") or "")
+        if not url:
+            raise RuntimeError("Feishu import succeeded without spreadsheet URL")
+        sheet_token = str(result.get("token") or "")
+        if sheet_token:
+            file_type = str(result.get("type") or "sheet")
+            if readonly_link:
+                self._set_read_permission(token, sheet_token, file_type)
+            else:
+                self._set_edit_permission(token, sheet_token, file_type)
+        return url
+
 
 def build_notification_summary(
     *,

+ 164 - 0
examples/tencent_realtime_control/test_historical_hourly_roi.py

@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import unittest
+from datetime import date
+from tempfile import TemporaryDirectory
+from pathlib import Path
+from unittest.mock import Mock
+
+import pandas as pd
+
+from export_historical_hourly_roi import build_hourly_report, create_workbook
+from feishu_notifier import FeishuNotifier
+
+
+class HistoricalHourlyRoiTest(unittest.TestCase):
+    def test_builds_full_hour_grid_and_ratios(self) -> None:
+        revenue_rows = pd.DataFrame(
+            [
+                {
+                    "dt": "20260811001500",
+                    "report_date": "20260811000000",
+                    "daily_exposure_cnt": 50,
+                    "overall_cpm": 180.0,
+                    "package_cost_times_today": 5.0,
+                },
+                {
+                    "dt": "20260811060000",
+                    "report_date": "20260811054500",
+                    "daily_exposure_cnt": 100,
+                    "overall_cpm": 200.0,
+                    "package_cost_times_today": 30.0,
+                },
+                {
+                    "dt": "20260811061500",
+                    "report_date": "20260811060000",
+                    "daily_exposure_cnt": 300,
+                    "overall_cpm": 240.0,
+                    "package_cost_times_today": 70.0,
+                },
+            ]
+        )
+        cost_rows = pd.DataFrame(
+            [
+                {
+                    "data_date": "20260811",
+                    "hour_of_day": 0,
+                    "total_uv": 2,
+                    "total_cost": 10.0,
+                    "total_unit_user_cost": 5.0,
+                    "miniapp_uv": 1,
+                    "miniapp_cost": 4.0,
+                    "miniapp_unit_user_cost": 4.0,
+                },
+                {
+                    "data_date": "20260811",
+                    "hour_of_day": 6,
+                    "total_uv": 10,
+                    "total_cost": 50.0,
+                    "total_unit_user_cost": 5.0,
+                    "miniapp_uv": 5,
+                    "miniapp_cost": 25.0,
+                    "miniapp_unit_user_cost": 5.0,
+                }
+            ]
+        )
+
+        report = build_hourly_report(
+            revenue_rows=revenue_rows,
+            cost_rows=cost_rows,
+            start_date=date(2026, 8, 11),
+            end_date=date(2026, 8, 11),
+        )
+
+        self.assertEqual(17, len(report))
+        hour_6 = report[report["小时"] == "06:00"].iloc[0]
+        self.assertEqual(50.0, hour_6["总投放成本"])
+        self.assertEqual(25.0, hour_6["小程序投流成本"])
+        self.assertEqual(70.0, hour_6["商业化收入"])
+        self.assertEqual(240.0, hour_6["CPM"])
+        self.assertEqual(1.4, hour_6["收入/总投放"])
+        self.assertEqual(2.8, hour_6["收入/小程序成本"])
+        self.assertEqual(1, hour_6["收入15分钟窗口数"])
+        self.assertEqual(105.0, hour_6["当日累计总收入"])
+        self.assertEqual(60.0, hour_6["当日累计总投放成本"])
+        self.assertEqual(29.0, hour_6["当日累计小程序投流成本"])
+        self.assertAlmostEqual(1.75, hour_6["当日累计收入/总投放"])
+        self.assertAlmostEqual(105.0 / 29.0, hour_6["当日累计收入/小程序成本"])
+        self.assertEqual("06:00", report.iloc[0]["小时"])
+        self.assertEqual("22:00", report.iloc[-1]["小时"])
+
+    def test_create_workbook_writes_xlsx(self) -> None:
+        report = build_hourly_report(
+            revenue_rows=pd.DataFrame(),
+            cost_rows=pd.DataFrame(),
+            start_date=date(2026, 8, 11),
+            end_date=date(2026, 8, 11),
+        )
+
+        with TemporaryDirectory() as directory:
+            path = create_workbook(
+                report=report,
+                start_date=date(2026, 8, 11),
+                end_date=date(2026, 8, 11),
+                miniapp_channel="小程序投流-稳定",
+                output_path=Path(directory) / "report.xlsx",
+            )
+
+            self.assertTrue(path.exists())
+            self.assertGreater(path.stat().st_size, 0)
+
+    def test_daily_cumulative_values_reset_on_the_next_date(self) -> None:
+        revenue_rows = pd.DataFrame(
+            [
+                {
+                    "dt": "20260811061500",
+                    "report_date": "20260811060000",
+                    "daily_exposure_cnt": 10,
+                    "overall_cpm": 200.0,
+                    "package_cost_times_today": 100.0,
+                },
+                {
+                    "dt": "20260812061500",
+                    "report_date": "20260812060000",
+                    "daily_exposure_cnt": 10,
+                    "overall_cpm": 200.0,
+                    "package_cost_times_today": 20.0,
+                },
+            ]
+        )
+
+        report = build_hourly_report(
+            revenue_rows=revenue_rows,
+            cost_rows=pd.DataFrame(),
+            start_date=date(2026, 8, 11),
+            end_date=date(2026, 8, 12),
+        )
+
+        day_2_hour_6 = report[
+            (report["日期"] == "2026-08-12") & (report["小时"] == "06:00")
+        ].iloc[0]
+        self.assertEqual(20.0, day_2_hour_6["当日累计总收入"])
+
+    def test_spreadsheet_import_defaults_to_editable_link(self) -> None:
+        notifier = FeishuNotifier()
+        notifier._require_drive_config = Mock()
+        notifier._tenant_token = Mock(return_value="tenant-token")
+        notifier._upload = Mock(return_value="file-token")
+        notifier._import_sheet = Mock(
+            return_value={"url": "https://example.test/sheet", "token": "sheet-token"}
+        )
+        notifier._set_read_permission = Mock()
+        notifier._set_edit_permission = Mock()
+
+        url = notifier.import_spreadsheet(Path("report.xlsx"))
+
+        self.assertEqual("https://example.test/sheet", url)
+        notifier._set_edit_permission.assert_called_once_with(
+            "tenant-token", "sheet-token", "sheet"
+        )
+        notifier._set_read_permission.assert_not_called()
+
+
+if __name__ == "__main__":
+    unittest.main()