test_roi_control_policy.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import os
  2. import unittest
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from unittest.mock import Mock, patch
  6. import pandas as pd
  7. from operator_commands import ACTION_CONFIRM, ACTION_REJECT, parse_command
  8. from roi_control.config import RoiConfig
  9. from roi_control.execution import _execute_pause_ad, plan_scale_bid
  10. from roi_control.metrics import ENTITY_GZH, ENTITY_SELF, ENTITY_SELF_AD
  11. from roi_control.policy import (
  12. ACTION_PAUSE_AD,
  13. ACTION_PAUSE_CREATIVE,
  14. ACTION_SCALE_BID,
  15. MODE_ACTIONABLE,
  16. MODE_NOTIFY_ONLY,
  17. annotate_execution,
  18. )
  19. from roi_control.repository import _is_expired
  20. from roi_control.sheet_approval import RoiSheetClient, parse_approval_rows
  21. from tencent_client import ACTIVE_STATUS, SUSPEND_STATUS
  22. class RoiControlPolicyTest(unittest.TestCase):
  23. def test_mysql_naive_expiry_compares_with_aware_service_time(self):
  24. expires_at = datetime(2026, 7, 28, 12, 0)
  25. now = datetime.fromisoformat("2026-07-28T12:01:00+08:00")
  26. self.assertTrue(_is_expired(expires_at, now))
  27. def test_actionability_is_separate_from_metric_recommendation(self):
  28. summary = pd.DataFrame(
  29. [
  30. {
  31. "entity_type": ENTITY_SELF,
  32. "channel": "小程序投流-稳定",
  33. "账号id": "84502354",
  34. "广告id": "1001",
  35. "创意id": "2001",
  36. "动作": "关停",
  37. "动作原因": "低ROI",
  38. },
  39. {
  40. "entity_type": ENTITY_SELF,
  41. "channel": "小程序投流-稳定",
  42. "账号id": "84502354",
  43. "广告id": "1001",
  44. "创意id": "2002",
  45. "动作": "扩量",
  46. "动作原因": "高ROI",
  47. },
  48. {
  49. "entity_type": ENTITY_SELF,
  50. "channel": "小程序投流-稳定",
  51. "账号id": "99999999",
  52. "广告id": "3001",
  53. "创意id": "4001",
  54. "动作": "关停",
  55. "动作原因": "低ROI",
  56. },
  57. {
  58. "entity_type": ENTITY_GZH,
  59. "channel": "公众号合作-即转-稳定",
  60. "合作方名": "合作方",
  61. "公众号名": "公众号",
  62. "动作": "扩量",
  63. "动作原因": "高ROI",
  64. },
  65. {
  66. "entity_type": ENTITY_SELF_AD,
  67. "channel": "小程序投流-稳定",
  68. "账号id": "84502354",
  69. "广告id": "5001",
  70. "创意id": "",
  71. "动作": "关停",
  72. "动作原因": "广告级低ROI",
  73. },
  74. ]
  75. )
  76. annotated, snapshots, actions = annotate_execution(
  77. summary,
  78. {84502354},
  79. "roi_20260725_north_star_roi_v1_roi_policy_v1",
  80. )
  81. self.assertEqual(len(snapshots), 5)
  82. self.assertEqual(
  83. {action["action_type"] for action in actions},
  84. {ACTION_PAUSE_CREATIVE, ACTION_PAUSE_AD, ACTION_SCALE_BID},
  85. )
  86. self.assertEqual(
  87. annotated.iloc[0]["执行模式"],
  88. MODE_ACTIONABLE,
  89. )
  90. self.assertEqual(annotated.iloc[2]["执行模式"], MODE_NOTIFY_ONLY)
  91. self.assertEqual(annotated.iloc[3]["执行模式"], MODE_NOTIFY_ONLY)
  92. self.assertEqual(annotated.iloc[0]["审批选择"], "")
  93. self.assertEqual(annotated.iloc[0]["执行状态"], "待审批")
  94. self.assertEqual(
  95. annotated.iloc[0]["动作幂等键"], actions[0]["idempotency_key"]
  96. )
  97. self.assertEqual(annotated.iloc[2]["审批选择"], "不可执行")
  98. self.assertEqual(annotated.iloc[4]["执行模式"], MODE_ACTIONABLE)
  99. self.assertEqual(annotated.iloc[4]["审批选择"], "")
  100. ad_action = next(
  101. action for action in actions if action["action_type"] == ACTION_PAUSE_AD
  102. )
  103. self.assertIsNone(ad_action["dynamic_creative_id"])
  104. self.assertTrue(ad_action["idempotency_key"].endswith(":5001"))
  105. def test_sheet_approval_parser_uses_hidden_idempotency_key(self):
  106. rows = [
  107. ["账号id", "审批选择", "动作幂等键", "执行状态", "执行结果"],
  108. ["99999999", "批准", "roi_x:PAUSE_CREATIVE:1:2", "待审批", ""],
  109. ["1", "拒绝", "roi_x:PAUSE_CREATIVE:1:3", "待审批", ""],
  110. ["1", "随便填", "roi_x:PAUSE_CREATIVE:1:4", "待审批", ""],
  111. ]
  112. decisions = parse_approval_rows(rows)
  113. self.assertEqual(
  114. [(row["decision"], row["idempotency_key"]) for row in decisions],
  115. [
  116. ("APPROVED", "roi_x:PAUSE_CREATIVE:1:2"),
  117. ("REJECTED", "roi_x:PAUSE_CREATIVE:1:3"),
  118. ],
  119. )
  120. def test_sheet_client_reads_both_current_approval_sheets(self):
  121. client = RoiSheetClient.__new__(RoiSheetClient)
  122. client._token = Mock(return_value="token")
  123. client._sheet_ids = Mock(
  124. return_value=[
  125. ("小程序创意级三日汇总", "creative-sheet"),
  126. ("小程序广告级三日汇总", "ad-sheet"),
  127. ]
  128. )
  129. client._read_sheet_approvals = Mock(
  130. side_effect=[
  131. [{"sheet_id": "creative-sheet", "idempotency_key": "creative"}],
  132. [{"sheet_id": "ad-sheet", "idempotency_key": "ad"}],
  133. ]
  134. )
  135. decisions = client.read_approvals("spreadsheet")
  136. self.assertEqual(
  137. [decision["sheet_id"] for decision in decisions],
  138. ["creative-sheet", "ad-sheet"],
  139. )
  140. def test_pause_ad_marks_prepared_then_success_after_readback(self):
  141. now = datetime.fromisoformat("2026-08-03T12:00:00+08:00")
  142. class FakeTencent:
  143. def get_ad(self, account_id, adgroup_id):
  144. self.get_args = (account_id, adgroup_id)
  145. return {
  146. "adgroup_id": adgroup_id,
  147. "configured_status": ACTIVE_STATUS,
  148. }
  149. def update_ad(self, account_id, adgroup_id, **kwargs):
  150. self.update_args = (account_id, adgroup_id, kwargs)
  151. return {
  152. "adgroup_id": adgroup_id,
  153. "configured_status": SUSPEND_STATUS,
  154. }
  155. client = FakeTencent()
  156. item = {
  157. "id": 99,
  158. "account_id": 84502354,
  159. "adgroup_id": 5001,
  160. "execution_status": "PENDING",
  161. }
  162. with patch("roi_control.execution.update_action_item") as update_item:
  163. _execute_pause_ad(item, client=client, now=now)
  164. self.assertEqual(client.get_args, (84502354, 5001))
  165. self.assertEqual(
  166. client.update_args,
  167. (84502354, 5001, {"target_status": SUSPEND_STATUS}),
  168. )
  169. self.assertEqual(update_item.call_count, 2)
  170. self.assertEqual(
  171. update_item.call_args_list[0].kwargs["execution_status"], "PREPARED"
  172. )
  173. self.assertEqual(
  174. update_item.call_args_list[1].kwargs["execution_status"], "SUCCESS"
  175. )
  176. self.assertEqual(
  177. update_item.call_args_list[1].kwargs["readback_status"], SUSPEND_STATUS
  178. )
  179. def test_scale_actions_are_deduplicated_by_ad(self):
  180. rows = [
  181. {
  182. "entity_type": ENTITY_SELF,
  183. "channel": "小程序投流-稳定",
  184. "账号id": "84502354",
  185. "广告id": "1001",
  186. "创意id": str(2000 + index),
  187. "动作": "扩量",
  188. "动作原因": "高ROI",
  189. }
  190. for index in range(3)
  191. ]
  192. _, _, actions = annotate_execution(
  193. pd.DataFrame(rows),
  194. {84502354},
  195. "roi_20260725_north_star_roi_v1_roi_policy_v1",
  196. )
  197. self.assertEqual(len(actions), 1)
  198. self.assertEqual(actions[0]["action_type"], ACTION_SCALE_BID)
  199. def test_observe_recommendation_never_creates_tencent_action(self):
  200. summary = pd.DataFrame(
  201. [
  202. {
  203. "entity_type": ENTITY_SELF,
  204. "channel": "小程序投流-稳定",
  205. "账号id": "84502354",
  206. "广告id": "1001",
  207. "创意id": "2001",
  208. "动作": "观察",
  209. "动作原因": "连续两天表现不一致",
  210. }
  211. ]
  212. )
  213. annotated, _, actions = annotate_execution(
  214. summary,
  215. {84502354},
  216. "roi_20260730_m6_p3",
  217. )
  218. self.assertEqual(actions, [])
  219. self.assertEqual(annotated.iloc[0]["执行模式"], MODE_NOTIFY_ONLY)
  220. self.assertEqual(
  221. annotated.iloc[0]["执行说明"],
  222. "观察行仅展示,不执行腾讯写操作",
  223. )
  224. def test_invalid_apply_without_daily_job_is_rejected(self):
  225. environment = {
  226. "DAILY_ROI_ENABLED": "0",
  227. "ROI_APPLY_ENABLED": "1",
  228. }
  229. with patch.dict(os.environ, environment, clear=False):
  230. with self.assertRaisesRegex(ValueError, "requires DAILY_ROI_ENABLED"):
  231. RoiConfig.from_env()
  232. def test_sheet_approval_requires_roi_writes(self):
  233. environment = {
  234. "DAILY_ROI_ENABLED": "1",
  235. "ROI_APPLY_ENABLED": "0",
  236. "ROI_SHEET_APPROVAL_ENABLED": "1",
  237. }
  238. with patch.dict(os.environ, environment, clear=False):
  239. with self.assertRaisesRegex(ValueError, "requires ROI_APPLY_ENABLED"):
  240. RoiConfig.from_env()
  241. def test_roi_batch_commands_are_deterministic(self):
  242. confirm = parse_command("确认 roi_20260725_north_star_roi_v1")
  243. reject = parse_command("拒绝 roi_20260725_north_star_roi_v1")
  244. old_confirm = parse_command("确认 cmd_20260725120000_abcd1234")
  245. self.assertEqual(confirm.action, ACTION_CONFIRM)
  246. self.assertEqual(reject.action, ACTION_REJECT)
  247. self.assertEqual(confirm.command_id, "roi_20260725_north_star_roi_v1")
  248. self.assertEqual(old_confirm.action, ACTION_CONFIRM)
  249. def test_restored_intraday_boost_is_not_reapplied_by_roi(self):
  250. plan = plan_scale_bid(
  251. current_bid_fen=100,
  252. base_bid_fen=100,
  253. initial_base_bid_fen=100,
  254. boosted_date=date(2026, 7, 26),
  255. control_date=date(2026, 7, 26),
  256. intraday_ratio=Decimal("1.25"),
  257. scale_ratio=Decimal("1.10"),
  258. max_base_ratio=Decimal("2.00"),
  259. )
  260. self.assertTrue(plan["allowed"])
  261. self.assertEqual(plan["new_base_bid_fen"], 110)
  262. self.assertEqual(plan["target_bid_fen"], 110)
  263. self.assertTrue(plan["boosted_date_today"])
  264. self.assertFalse(plan["preserve_intraday_boost"])
  265. def test_active_intraday_boost_is_preserved_relative_to_new_base(self):
  266. plan = plan_scale_bid(
  267. current_bid_fen=125,
  268. base_bid_fen=100,
  269. initial_base_bid_fen=100,
  270. boosted_date=date(2026, 7, 26),
  271. control_date=date(2026, 7, 26),
  272. intraday_ratio=Decimal("1.25"),
  273. scale_ratio=Decimal("1.10"),
  274. max_base_ratio=Decimal("2.00"),
  275. )
  276. self.assertEqual(plan["target_bid_fen"], 138)
  277. self.assertTrue(plan["preserve_intraday_boost"])
  278. if __name__ == "__main__":
  279. unittest.main()