test_roi_control_policy.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import os
  2. import unittest
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from unittest.mock import 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 plan_scale_bid
  10. from roi_control.metrics import ENTITY_GZH, ENTITY_SELF
  11. from roi_control.policy import (
  12. ACTION_PAUSE_CREATIVE,
  13. ACTION_SCALE_BID,
  14. MODE_ACTIONABLE,
  15. MODE_NOTIFY_ONLY,
  16. annotate_execution,
  17. )
  18. from roi_control.repository import _is_expired
  19. from roi_control.sheet_approval import parse_approval_rows
  20. class RoiControlPolicyTest(unittest.TestCase):
  21. def test_mysql_naive_expiry_compares_with_aware_service_time(self):
  22. expires_at = datetime(2026, 7, 28, 12, 0)
  23. now = datetime.fromisoformat("2026-07-28T12:01:00+08:00")
  24. self.assertTrue(_is_expired(expires_at, now))
  25. def test_actionability_is_separate_from_metric_recommendation(self):
  26. summary = pd.DataFrame(
  27. [
  28. {
  29. "entity_type": ENTITY_SELF,
  30. "channel": "小程序投流-稳定",
  31. "账号id": "84502354",
  32. "广告id": "1001",
  33. "创意id": "2001",
  34. "动作": "关停",
  35. "动作原因": "低ROI",
  36. },
  37. {
  38. "entity_type": ENTITY_SELF,
  39. "channel": "小程序投流-稳定",
  40. "账号id": "84502354",
  41. "广告id": "1001",
  42. "创意id": "2002",
  43. "动作": "扩量",
  44. "动作原因": "高ROI",
  45. },
  46. {
  47. "entity_type": ENTITY_SELF,
  48. "channel": "小程序投流-稳定",
  49. "账号id": "99999999",
  50. "广告id": "3001",
  51. "创意id": "4001",
  52. "动作": "关停",
  53. "动作原因": "低ROI",
  54. },
  55. {
  56. "entity_type": ENTITY_GZH,
  57. "channel": "公众号合作-即转-稳定",
  58. "合作方名": "合作方",
  59. "公众号名": "公众号",
  60. "动作": "扩量",
  61. "动作原因": "高ROI",
  62. },
  63. ]
  64. )
  65. annotated, snapshots, actions = annotate_execution(
  66. summary,
  67. {84502354},
  68. "roi_20260725_north_star_roi_v1_roi_policy_v1",
  69. )
  70. self.assertEqual(len(snapshots), 4)
  71. self.assertEqual(
  72. {action["action_type"] for action in actions},
  73. {ACTION_PAUSE_CREATIVE, ACTION_SCALE_BID},
  74. )
  75. self.assertEqual(
  76. annotated.iloc[0]["执行模式"],
  77. MODE_ACTIONABLE,
  78. )
  79. self.assertEqual(annotated.iloc[2]["执行模式"], MODE_NOTIFY_ONLY)
  80. self.assertEqual(annotated.iloc[3]["执行模式"], MODE_NOTIFY_ONLY)
  81. self.assertEqual(annotated.iloc[0]["审批选择"], "")
  82. self.assertEqual(annotated.iloc[0]["执行状态"], "待审批")
  83. self.assertEqual(
  84. annotated.iloc[0]["动作幂等键"], actions[0]["idempotency_key"]
  85. )
  86. self.assertEqual(annotated.iloc[2]["审批选择"], "不可执行")
  87. def test_sheet_approval_parser_uses_hidden_idempotency_key(self):
  88. rows = [
  89. ["账号id", "审批选择", "动作幂等键", "执行状态", "执行结果"],
  90. ["99999999", "批准", "roi_x:PAUSE_CREATIVE:1:2", "待审批", ""],
  91. ["1", "拒绝", "roi_x:PAUSE_CREATIVE:1:3", "待审批", ""],
  92. ["1", "随便填", "roi_x:PAUSE_CREATIVE:1:4", "待审批", ""],
  93. ]
  94. decisions = parse_approval_rows(rows)
  95. self.assertEqual(
  96. [(row["decision"], row["idempotency_key"]) for row in decisions],
  97. [
  98. ("APPROVED", "roi_x:PAUSE_CREATIVE:1:2"),
  99. ("REJECTED", "roi_x:PAUSE_CREATIVE:1:3"),
  100. ],
  101. )
  102. def test_scale_actions_are_deduplicated_by_ad(self):
  103. rows = [
  104. {
  105. "entity_type": ENTITY_SELF,
  106. "channel": "小程序投流-稳定",
  107. "账号id": "84502354",
  108. "广告id": "1001",
  109. "创意id": str(2000 + index),
  110. "动作": "扩量",
  111. "动作原因": "高ROI",
  112. }
  113. for index in range(3)
  114. ]
  115. _, _, actions = annotate_execution(
  116. pd.DataFrame(rows),
  117. {84502354},
  118. "roi_20260725_north_star_roi_v1_roi_policy_v1",
  119. )
  120. self.assertEqual(len(actions), 1)
  121. self.assertEqual(actions[0]["action_type"], ACTION_SCALE_BID)
  122. def test_invalid_apply_without_daily_job_is_rejected(self):
  123. environment = {
  124. "DAILY_ROI_ENABLED": "0",
  125. "ROI_APPLY_ENABLED": "1",
  126. }
  127. with patch.dict(os.environ, environment, clear=False):
  128. with self.assertRaisesRegex(ValueError, "requires DAILY_ROI_ENABLED"):
  129. RoiConfig.from_env()
  130. def test_sheet_approval_requires_roi_writes(self):
  131. environment = {
  132. "DAILY_ROI_ENABLED": "1",
  133. "ROI_APPLY_ENABLED": "0",
  134. "ROI_SHEET_APPROVAL_ENABLED": "1",
  135. }
  136. with patch.dict(os.environ, environment, clear=False):
  137. with self.assertRaisesRegex(ValueError, "requires ROI_APPLY_ENABLED"):
  138. RoiConfig.from_env()
  139. def test_roi_batch_commands_are_deterministic(self):
  140. confirm = parse_command("确认 roi_20260725_north_star_roi_v1")
  141. reject = parse_command("拒绝 roi_20260725_north_star_roi_v1")
  142. old_confirm = parse_command("确认 cmd_20260725120000_abcd1234")
  143. self.assertEqual(confirm.action, ACTION_CONFIRM)
  144. self.assertEqual(reject.action, ACTION_REJECT)
  145. self.assertEqual(confirm.command_id, "roi_20260725_north_star_roi_v1")
  146. self.assertEqual(old_confirm.action, ACTION_CONFIRM)
  147. def test_restored_intraday_boost_is_not_reapplied_by_roi(self):
  148. plan = plan_scale_bid(
  149. current_bid_fen=100,
  150. base_bid_fen=100,
  151. initial_base_bid_fen=100,
  152. boosted_date=date(2026, 7, 26),
  153. control_date=date(2026, 7, 26),
  154. intraday_ratio=Decimal("1.25"),
  155. scale_ratio=Decimal("1.10"),
  156. max_base_ratio=Decimal("2.00"),
  157. )
  158. self.assertTrue(plan["allowed"])
  159. self.assertEqual(plan["new_base_bid_fen"], 110)
  160. self.assertEqual(plan["target_bid_fen"], 110)
  161. self.assertTrue(plan["boosted_date_today"])
  162. self.assertFalse(plan["preserve_intraday_boost"])
  163. def test_active_intraday_boost_is_preserved_relative_to_new_base(self):
  164. plan = plan_scale_bid(
  165. current_bid_fen=125,
  166. base_bid_fen=100,
  167. initial_base_bid_fen=100,
  168. boosted_date=date(2026, 7, 26),
  169. control_date=date(2026, 7, 26),
  170. intraday_ratio=Decimal("1.25"),
  171. scale_ratio=Decimal("1.10"),
  172. max_base_ratio=Decimal("2.00"),
  173. )
  174. self.assertEqual(plan["target_bid_fen"], 138)
  175. self.assertTrue(plan["preserve_intraday_boost"])
  176. if __name__ == "__main__":
  177. unittest.main()