test_roi_control_metrics.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950
  1. import tempfile
  2. import unittest
  3. from datetime import datetime
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. import pandas as pd
  7. from openpyxl import load_workbook
  8. from roi_control.data_source import (
  9. SourceDataNotReadyError,
  10. build_daily_sql,
  11. build_source_readiness_sql,
  12. date_window,
  13. resolve_end_date,
  14. )
  15. from roi_control.fission_multiplier import (
  16. DISPLAY_MULTIPLIER_COLUMN,
  17. load_fission_multiplier_parameters,
  18. )
  19. from roi_control.metrics import (
  20. ENTITY_GZH,
  21. ENTITY_SELF,
  22. ENTITY_SELF_AD,
  23. GZH_CHANNEL,
  24. SELF_CHANNEL,
  25. prepare_daily_metrics,
  26. )
  27. from roi_control.reporting import (
  28. AGENCY_SUMMARY_SHEETS,
  29. DAILY_SHEETS,
  30. SUMMARY_SHEETS,
  31. _agency_summary_frame,
  32. _daily_frame,
  33. _summary_frame,
  34. _visible_columns,
  35. write_agency_workbooks,
  36. write_workbook,
  37. )
  38. from roi_control.rules import evaluate_rules as _evaluate_rules
  39. from roi_control.service import _annotate_current_creative_status, _run_identity
  40. from tencent_client import ACTIVE_STATUS, SUSPEND_STATUS
  41. DATES = ["20260720", "20260721", "20260722"]
  42. FISSION_PARAMETERS = load_fission_multiplier_parameters()
  43. def evaluate_rules(*args, **kwargs):
  44. kwargs["fission_parameters"] = FISSION_PARAMETERS
  45. return _evaluate_rules(*args, **kwargs)
  46. def row(entity_type, entity_id, dt, roi, *, uv=600, cost=200.0):
  47. common = {
  48. "dt": dt,
  49. "entity_type": entity_type,
  50. "channel": SELF_CHANNEL if entity_type != ENTITY_GZH else GZH_CHANNEL,
  51. "代理名称": "",
  52. "账号id": "",
  53. "账号名称": "",
  54. "广告id": "",
  55. "广告名称": "",
  56. "包名": "",
  57. "广告优化目标": "",
  58. "创意id": "",
  59. "合作方名": "",
  60. "公众号名": "",
  61. "首层UV": uv,
  62. "T0裂变数": uv * 0.2,
  63. "成本": cost,
  64. "效率收入": roi * cost,
  65. "裂变效率收入": 0,
  66. }
  67. if entity_type in (ENTITY_SELF, ENTITY_SELF_AD):
  68. common.update(
  69. {
  70. "代理名称": "代理",
  71. "账号id": "84502354",
  72. "账号名称": "账户",
  73. "广告id": entity_id,
  74. "广告名称": f"广告{entity_id}",
  75. "包名": "泛人群",
  76. "广告优化目标": "关键页面访问次数",
  77. "创意id": f"creative-{entity_id}" if entity_type == ENTITY_SELF else "",
  78. }
  79. )
  80. else:
  81. common.update({"合作方名": "合作方", "公众号名": entity_id})
  82. return common
  83. class RoiThreeDayRulesTest(unittest.TestCase):
  84. @staticmethod
  85. def source_client(latest_dt, *, row_count=100, self_rows=60, gzh_rows=40):
  86. partition = SimpleNamespace(partition_spec={"dt": latest_dt})
  87. table = SimpleNamespace(get_max_partition=lambda: partition)
  88. odps = SimpleNamespace(get_table=lambda _name: table)
  89. return SimpleNamespace(
  90. odps=odps,
  91. execute_sql=lambda _sql: pd.DataFrame(
  92. [
  93. {
  94. "row_count": row_count,
  95. "self_rows": self_rows,
  96. "gzh_rows": gzh_rows,
  97. }
  98. ]
  99. ),
  100. )
  101. def test_source_readiness_requires_exact_t_minus_one(self):
  102. client = self.source_client("20260803")
  103. end_date = resolve_end_date(
  104. client,
  105. now=datetime(2026, 8, 4, 9, 0),
  106. )
  107. self.assertEqual(end_date, "20260803")
  108. sql = build_source_readiness_sql(end_date)
  109. self.assertIn("dt = '20260803'", sql)
  110. self.assertIn(SELF_CHANNEL, sql)
  111. self.assertIn(GZH_CHANNEL, sql)
  112. def test_source_readiness_never_falls_back_to_older_partition(self):
  113. client = self.source_client("20260802")
  114. with self.assertRaisesRegex(SourceDataNotReadyError, "required_dt=20260803"):
  115. resolve_end_date(client, now=datetime(2026, 8, 4, 9, 0))
  116. def test_source_readiness_rejects_missing_report_channel(self):
  117. client = self.source_client("20260803", gzh_rows=0)
  118. with self.assertRaisesRegex(SourceDataNotReadyError, "gzh_rows"):
  119. resolve_end_date(client, now=datetime(2026, 8, 4, 9, 0))
  120. def build_daily(self):
  121. rows = []
  122. for dt in DATES:
  123. for index, roi in enumerate([0.1, 1.0, 3.0, 4.0]):
  124. rows.append(row(ENTITY_SELF, f"creative-ad-{index}", dt, roi))
  125. for index, roi in enumerate([0.5, 2.0, 5.0, 6.0]):
  126. rows.append(row(ENTITY_GZH, f"公众号-{index}", dt, roi, uv=300))
  127. for index, roi in enumerate([0.2, 3.5]):
  128. rows.append(row(ENTITY_SELF_AD, f"ad-{index}", dt, roi))
  129. return pd.DataFrame(rows)
  130. def ages(self):
  131. return pd.DataFrame(
  132. {
  133. "广告id": [f"creative-ad-{i}" for i in range(4)]
  134. + [f"ad-{i}" for i in range(2)],
  135. "广告age": [10] * 6,
  136. }
  137. )
  138. def test_versions_and_three_day_window(self):
  139. self.assertEqual(date_window("20260722"), ("20260720", "20260722"))
  140. run_id, _ = _run_identity("20260722", FISSION_PARAMETERS)
  141. self.assertIn("m8", run_id)
  142. self.assertIn("p15", run_id)
  143. self.assertIn("r41", run_id)
  144. def test_current_creative_status_only_reads_stop_decisions(self):
  145. rows = pd.DataFrame(
  146. [
  147. {"entity_type": ENTITY_SELF, "动作": "关停", "账号id": "1", "创意id": "11"},
  148. {"entity_type": ENTITY_SELF, "动作": "扩量", "账号id": "1", "创意id": "12"},
  149. {"entity_type": ENTITY_SELF, "动作": "关停", "账号id": "1", "创意id": "13"},
  150. {"entity_type": ENTITY_GZH, "动作": "关停", "账号id": "", "创意id": ""},
  151. ]
  152. )
  153. class FakeTencent:
  154. def __init__(self):
  155. self.calls = []
  156. def get_dynamic_creative(self, account_id, creative_id):
  157. self.calls.append((account_id, creative_id))
  158. return {
  159. "configured_status": (
  160. ACTIVE_STATUS if creative_id == 11 else SUSPEND_STATUS
  161. )
  162. }
  163. client = FakeTencent()
  164. result = _annotate_current_creative_status(rows, client)
  165. self.assertEqual(client.calls, [(1, 11), (1, 13)])
  166. self.assertEqual(
  167. result["当前创意状态"].tolist(),
  168. ["正常", "", "已停止", ""],
  169. )
  170. def test_daily_sql_has_direct_ad_grain_and_excludes_qiwei(self):
  171. sql = build_daily_sql(DATES[0], DATES[-1])
  172. self.assertEqual(sql.count("SUM(NVL(t0_fission_uv_root, 0))"), 3)
  173. self.assertIn("'self_ad' AS entity_type", sql)
  174. self.assertNotIn("'qiwei' AS entity_type", sql)
  175. self.assertIn("COUNT(DISTINCT mid) AS 首层UV", sql)
  176. self.assertIn("usersharedepth <= 1", sql)
  177. self.assertNotIn("usersharedepth = '0'", sql)
  178. def test_channel_independent_stop_samples_and_creative_only_p80_scale_actions(self):
  179. candidates, thresholds, summary = evaluate_rules(
  180. self.build_daily(), DATES, self.ages()
  181. )
  182. self.assertEqual(len(thresholds), 2)
  183. by_type = thresholds.set_index("entity_type")
  184. self.assertTrue(pd.isna(by_type.loc[ENTITY_SELF, "t_stop"]))
  185. self.assertAlmostEqual(float(by_type.loc[ENTITY_GZH, "t_stop"]), 1.4)
  186. self.assertAlmostEqual(float(by_type.loc[ENTITY_SELF, "t_up"]), 3.4)
  187. self.assertEqual(int(by_type.loc[ENTITY_SELF, "阈值样本数"]), 4)
  188. self.assertEqual(int(by_type.loc[ENTITY_GZH, "阈值样本数"]), 4)
  189. self.assertEqual(int(by_type.loc[ENTITY_SELF, "扩量样本数"]), 4)
  190. self.assertEqual(
  191. by_type.loc[ENTITY_SELF, "关停线口径"],
  192. "T-1实际成本5%软预算动态线",
  193. )
  194. self.assertEqual(
  195. by_type.loc[ENTITY_SELF, "扩量线口径"],
  196. "合格小程序创意实体等权P80",
  197. )
  198. scale_rows = candidates[candidates["动作"].eq("扩量")]
  199. self.assertEqual(len(scale_rows), 1)
  200. self.assertEqual(scale_rows.iloc[0]["entity_type"], ENTITY_SELF)
  201. self.assertEqual(scale_rows.iloc[0]["广告id"], "creative-ad-3")
  202. self.assertTrue(scale_rows.iloc[0]["是否位于创意三日ROI前20%"])
  203. self.assertTrue((summary["覆盖天数"] == 3).all())
  204. pool = summary[summary["entity_type"].isin([ENTITY_SELF, ENTITY_GZH])]
  205. self.assertTrue(
  206. pool["阈值样本状态"].eq("进入三日渠道独立阈值样本池").all()
  207. )
  208. def test_self_stop_budget_uses_latest_cost_with_six_four_soft_allocation(self):
  209. rows = []
  210. formal = [
  211. ("formal-1", 0.10, 30.0),
  212. ("formal-2", 0.20, 30.0),
  213. ("formal-3", 1.00, 500.0),
  214. ("formal-4", 2.00, 500.0),
  215. ]
  216. for entity_id, roi, latest_cost in formal:
  217. for dt in DATES:
  218. rows.append(
  219. row(
  220. ENTITY_SELF,
  221. entity_id,
  222. dt,
  223. roi,
  224. cost=latest_cost if dt == DATES[-1] else 100.0,
  225. )
  226. )
  227. rows.extend(
  228. [
  229. row(ENTITY_SELF, "hard-1", DATES[-1], 0.05, uv=300, cost=20.0),
  230. row(ENTITY_SELF, "hard-2", DATES[-1], 0.10, uv=300, cost=200.0),
  231. row(ENTITY_SELF, "rank-1", DATES[-1], 0.05, uv=600, cost=20.0),
  232. row(ENTITY_SELF, "rank-2", DATES[-1], 0.40, uv=600, cost=700.0),
  233. ]
  234. )
  235. ages = pd.DataFrame(
  236. {
  237. "广告id": [item[0] for item in formal]
  238. + ["hard-1", "hard-2", "rank-1", "rank-2"],
  239. "广告age": [10] * 8,
  240. }
  241. )
  242. _, thresholds, summary = evaluate_rules(pd.DataFrame(rows), DATES, ages)
  243. self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
  244. selected = summary[
  245. summary["entity_type"].eq(ENTITY_SELF) & summary["动作"].eq("关停")
  246. ]
  247. selected_ids = set(selected["广告id"])
  248. latest_cost = selected[f"成本_{DATES[-1]}"].sum()
  249. self.assertEqual(
  250. selected_ids,
  251. {"formal-1", "formal-2", "hard-1", "rank-1"},
  252. )
  253. self.assertAlmostEqual(float(latest_cost), 100.0)
  254. self.assertAlmostEqual(float(self_threshold["小程序昨日总成本"]), 2000.0)
  255. self.assertAlmostEqual(float(self_threshold["目标关停成本"]), 100.0)
  256. self.assertAlmostEqual(float(self_threshold["实际关停成本"]), 100.0)
  257. self.assertAlmostEqual(float(self_threshold["实际关停成本占比"]), 0.05)
  258. self.assertEqual(self_threshold["关停成本预算状态"], "正常范围")
  259. self.assertAlmostEqual(float(self_threshold["三日基础预算成本"]), 60.0)
  260. self.assertAlmostEqual(float(self_threshold["单日基础预算成本"]), 40.0)
  261. self.assertAlmostEqual(float(self_threshold["三日关停线"]), 0.20)
  262. self.assertAlmostEqual(float(self_threshold["三日关停线分位点"]), 0.50)
  263. self.assertAlmostEqual(float(self_threshold["单日P10线"]), 0.05)
  264. self.assertAlmostEqual(float(self_threshold["单日合并资格线"]), 0.05)
  265. self.assertAlmostEqual(float(self_threshold["单日实际关停线"]), 0.05)
  266. self.assertEqual(
  267. selected.groupby("关停规则")[f"成本_{DATES[-1]}"].sum().to_dict(),
  268. {
  269. "三日持续低ROI": 60.0,
  270. "单日绝对低ROI且P10": 40.0,
  271. },
  272. )
  273. def test_self_and_official_account_stop_lines_are_independent(self):
  274. daily = self.build_daily()
  275. latest_costs = {
  276. "creative-ad-0": 10.0,
  277. "creative-ad-1": 330.0,
  278. "creative-ad-2": 330.0,
  279. "creative-ad-3": 330.0,
  280. }
  281. for entity_id, cost in latest_costs.items():
  282. mask = (
  283. daily["entity_type"].eq(ENTITY_SELF)
  284. & daily["广告id"].eq(entity_id)
  285. & daily["dt"].eq(DATES[-1])
  286. )
  287. roi = float(daily.loc[mask, "效率收入"].iloc[0]) / float(
  288. daily.loc[mask, "成本"].iloc[0]
  289. )
  290. daily.loc[mask, "成本"] = cost
  291. daily.loc[mask, "效率收入"] = roi * cost
  292. _, thresholds, summary = evaluate_rules(daily, DATES, self.ages())
  293. by_type = thresholds.set_index("entity_type")
  294. self.assertEqual(set(by_type.index), {ENTITY_SELF, ENTITY_GZH})
  295. self.assertAlmostEqual(float(by_type.loc[ENTITY_SELF, "t_stop"]), 0.10)
  296. self.assertAlmostEqual(
  297. float(by_type.loc[ENTITY_SELF, "关停线分位点"]),
  298. 0.25,
  299. )
  300. self.assertAlmostEqual(float(by_type.loc[ENTITY_GZH, "t_stop"]), 1.40)
  301. self.assertAlmostEqual(
  302. float(by_type.loc[ENTITY_GZH, "关停线分位点"]),
  303. 0.20,
  304. )
  305. self_rows = summary[summary["entity_type"].eq(ENTITY_SELF)]
  306. gzh_rows = summary[summary["entity_type"].eq(ENTITY_GZH)]
  307. self.assertTrue(self_rows["t_stop"].eq(0.10).all())
  308. self.assertTrue(
  309. gzh_rows["t_stop"].apply(
  310. lambda value: abs(float(value) - 1.40) < 1e-9
  311. ).all()
  312. )
  313. def test_unused_one_day_budget_rolls_to_three_day_candidates(self):
  314. rows = []
  315. formal = [
  316. ("formal-roll-1", 0.10, 60.0),
  317. ("formal-roll-2", 0.20, 20.0),
  318. ("formal-roll-3", 1.00, 900.0),
  319. ]
  320. for entity_id, roi, latest_cost in formal:
  321. for dt in DATES:
  322. rows.append(
  323. row(
  324. ENTITY_SELF,
  325. entity_id,
  326. dt,
  327. roi,
  328. cost=latest_cost if dt == DATES[-1] else 100.0,
  329. )
  330. )
  331. rows.extend(
  332. [
  333. row(ENTITY_SELF, "rank-roll-1", DATES[-1], 0.05, uv=600, cost=20),
  334. row(ENTITY_SELF, "rank-roll-2", DATES[-1], 0.40, uv=600, cost=1000),
  335. ]
  336. )
  337. ages = pd.DataFrame(
  338. {
  339. "广告id": [item[0] for item in formal]
  340. + ["rank-roll-1", "rank-roll-2"],
  341. "广告age": [10] * 5,
  342. }
  343. )
  344. _, thresholds, summary = evaluate_rules(pd.DataFrame(rows), DATES, ages)
  345. selected = summary[
  346. summary["entity_type"].eq(ENTITY_SELF) & summary["动作"].eq("关停")
  347. ]
  348. self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
  349. self.assertEqual(
  350. set(selected["广告id"]),
  351. {"formal-roll-1", "formal-roll-2", "rank-roll-1"},
  352. )
  353. self.assertAlmostEqual(float(self_threshold["实际关停成本"]), 100.0)
  354. self.assertAlmostEqual(float(self_threshold["三日基础预算成本"]), 60.0)
  355. self.assertAlmostEqual(float(self_threshold["三日实际关停成本"]), 80.0)
  356. self.assertAlmostEqual(float(self_threshold["单日实际关停成本"]), 20.0)
  357. self.assertEqual(self_threshold["关停成本预算状态"], "正常范围")
  358. def test_creative_p80_scale_requires_ad_age_at_least_three_days(self):
  359. ages = self.ages()
  360. ages.loc[ages["广告id"].eq("creative-ad-3"), "广告age"] = 2
  361. candidates, _, _ = evaluate_rules(self.build_daily(), DATES, ages)
  362. target = candidates[candidates["广告id"].eq("creative-ad-3")].iloc[0]
  363. self.assertEqual(target["动作"], "观察")
  364. self.assertIn("广告age<3天", target["动作原因"])
  365. def test_ad_level_reuses_threshold_without_entering_sample_and_can_stop(self):
  366. daily = self.build_daily()
  367. low_creative = daily["entity_type"].eq(ENTITY_SELF) & daily["广告id"].eq(
  368. "creative-ad-0"
  369. )
  370. latest_low = low_creative & daily["dt"].eq(DATES[-1])
  371. daily.loc[latest_low, ["成本", "效率收入"]] = [10.0, 1.0]
  372. low_ad = daily["entity_type"].eq(ENTITY_SELF_AD) & daily["广告id"].eq("ad-0")
  373. daily.loc[low_ad, "效率收入"] = daily.loc[low_ad, "成本"] * 0.05
  374. _, thresholds, summary = evaluate_rules(daily, DATES, self.ages())
  375. ad_rows = summary[summary["entity_type"].eq(ENTITY_SELF_AD)]
  376. self.assertTrue(
  377. ad_rows["阈值样本状态"].eq("广告级三日合格_不进入阈值样本池").all()
  378. )
  379. actions = dict(zip(ad_rows["广告id"], ad_rows["动作"]))
  380. self.assertEqual(actions["ad-0"], "关停")
  381. self.assertEqual(actions["ad-1"], "")
  382. self_line = float(
  383. thresholds.set_index("entity_type").loc[ENTITY_SELF, "t_stop"]
  384. )
  385. self.assertTrue(ad_rows["t_stop"].eq(self_line).all())
  386. self.assertTrue(ad_rows["调控参与状态"].str.contains("审批后可暂停广告").all())
  387. def test_latest_day_uv_over_200_is_appended_with_fixed_three_day_average(self):
  388. daily = self.build_daily()
  389. latest_only = row(ENTITY_SELF, "latest-only", DATES[-1], 1.5, uv=300)
  390. daily = pd.concat([daily, pd.DataFrame([latest_only])], ignore_index=True)
  391. candidates, _, summary = evaluate_rules(daily, DATES, self.ages())
  392. target = summary[summary["广告id"].eq("latest-only")].iloc[0]
  393. self.assertEqual(target["覆盖天数"], 1)
  394. self.assertEqual(target["阈值样本状态"], "单日补充决策_昨日UV>200")
  395. self.assertEqual(target["动作"], "观察")
  396. self.assertEqual(target["日均首层UV"], 100)
  397. self.assertTrue(candidates["广告id"].eq("latest-only").any())
  398. def test_one_day_rule_requires_absolute_low_roi_and_p10_and_age(self):
  399. daily = self.build_daily()
  400. supplemental = [
  401. row(ENTITY_SELF, "one-day-both", DATES[-1], 0.05, uv=300, cost=10),
  402. row(ENTITY_SELF, "one-day-not-p10", DATES[-1], 0.15, uv=300, cost=10),
  403. row(ENTITY_SELF, "one-day-young", DATES[-1], 0.01, uv=300, cost=10),
  404. row(ENTITY_SELF, "one-day-mid", DATES[-1], 1.0, uv=300, cost=10),
  405. row(ENTITY_SELF, "one-day-high", DATES[-1], 2.0, uv=300, cost=900),
  406. ]
  407. daily = pd.concat([daily, pd.DataFrame(supplemental)], ignore_index=True)
  408. ages = pd.concat(
  409. [
  410. self.ages(),
  411. pd.DataFrame(
  412. {
  413. "广告id": [
  414. "one-day-both",
  415. "one-day-not-p10",
  416. "one-day-young",
  417. "one-day-mid",
  418. "one-day-high",
  419. ],
  420. "广告age": [4, 4, 3, 4, 4],
  421. }
  422. ),
  423. ],
  424. ignore_index=True,
  425. )
  426. _, thresholds, summary = evaluate_rules(daily, DATES, ages)
  427. self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
  428. self.assertEqual(int(self_threshold["单日候选池样本数"]), 4)
  429. self.assertAlmostEqual(float(self_threshold["单日P10线"]), 0.08)
  430. self.assertAlmostEqual(float(self_threshold["单日合并资格线"]), 0.08)
  431. targets = summary.set_index("广告id")
  432. self.assertEqual(targets.loc["one-day-both", "动作"], "关停")
  433. self.assertEqual(
  434. targets.loc["one-day-both", "关停规则"],
  435. "单日绝对低ROI且P10",
  436. )
  437. self.assertIn("同时满足", targets.loc["one-day-both", "动作原因"])
  438. self.assertIn("ROI≤0.20", targets.loc["one-day-both", "动作原因"])
  439. self.assertIn("P10", targets.loc["one-day-both", "动作原因"])
  440. self.assertEqual(targets.loc["one-day-not-p10", "动作"], "观察")
  441. self.assertIn("未进入后10%", targets.loc["one-day-not-p10", "动作原因"])
  442. self.assertEqual(targets.loc["one-day-young", "动作"], "观察")
  443. self.assertIn("广告age=3≤3天", targets.loc["one-day-young", "动作原因"])
  444. creative_frame = _summary_frame(summary, ENTITY_SELF)
  445. creative_stops = creative_frame[
  446. creative_frame["建议动作"].eq("关停创意")
  447. ]
  448. self.assertIn("one-day-both", creative_stops["广告id"].tolist())
  449. self.assertNotIn("one-day-not-p10", creative_stops["广告id"].tolist())
  450. self.assertTrue(creative_stops["动作"].eq("关停").all())
  451. self.assertTrue(
  452. summary[
  453. summary["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
  454. & summary["动作"].eq("关停")
  455. ]["广告age"].ge(4).all()
  456. )
  457. def test_one_day_p10_without_absolute_low_roi_is_observe(self):
  458. daily = self.build_daily()
  459. supplemental = [
  460. row(ENTITY_SELF, f"one-day-high-{index}", DATES[-1], roi, uv=300, cost=10)
  461. for index, roi in enumerate([0.30, 0.40, 0.50, 0.60, 0.70])
  462. ]
  463. daily = pd.concat([daily, pd.DataFrame(supplemental)], ignore_index=True)
  464. ages = pd.concat(
  465. [
  466. self.ages(),
  467. pd.DataFrame(
  468. {
  469. "广告id": [f"one-day-high-{index}" for index in range(5)],
  470. "广告age": [10] * 5,
  471. }
  472. ),
  473. ],
  474. ignore_index=True,
  475. )
  476. _, thresholds, summary = evaluate_rules(daily, DATES, ages)
  477. self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
  478. target = summary[summary["广告id"].eq("one-day-high-0")].iloc[0]
  479. self.assertGreater(float(self_threshold["单日P10线"]), 0.20)
  480. self.assertAlmostEqual(float(self_threshold["单日合并资格线"]), 0.20)
  481. self.assertEqual(target["动作"], "观察")
  482. self.assertIn("高于绝对线0.20", target["动作原因"])
  483. def test_three_day_formal_entity_never_reenters_one_day_rule(self):
  484. daily = self.build_daily()
  485. ages = self.ages()
  486. _, _, summary = evaluate_rules(daily, DATES, ages)
  487. target = summary[summary["广告id"].eq("creative-ad-0")].iloc[0]
  488. self.assertEqual(target["阈值样本状态"], "进入三日渠道独立阈值样本池")
  489. self.assertNotEqual(target["关停规则"], "单日绝对低ROI且P10")
  490. self.assertNotIn("单日", target["动作原因"])
  491. def test_daily_prediction_uses_same_day_t0_fission_revenue_once(self):
  492. raw = pd.DataFrame([row(ENTITY_SELF, "formula", DATES[-1], 0)])
  493. raw.loc[0, ["成本", "效率收入", "裂变效率收入"]] = [100, 50, 30]
  494. result = prepare_daily_metrics(raw, FISSION_PARAMETERS).iloc[0]
  495. multiplier = result[DISPLAY_MULTIPLIER_COLUMN]
  496. self.assertEqual(result["T0实际裂变收入"], 30)
  497. self.assertAlmostEqual(result["预测全链路效率收入"], 50 + 30 * multiplier)
  498. self.assertAlmostEqual(result["ROI"], (50 + 30 * multiplier) / 100)
  499. def test_agency_workbooks_filter_and_physically_remove_sensitive_columns(self):
  500. _, _, summary = evaluate_rules(self.build_daily(), DATES, self.ages())
  501. agency_rows = summary.copy()
  502. creative_indexes = agency_rows[
  503. agency_rows["entity_type"].eq(ENTITY_SELF)
  504. ].index.tolist()
  505. ad_indexes = agency_rows[
  506. agency_rows["entity_type"].eq(ENTITY_SELF_AD)
  507. ].index.tolist()
  508. agency_rows.loc[creative_indexes, "代理名称"] = "代理B"
  509. agency_rows.loc[ad_indexes, "代理名称"] = "代理B"
  510. agency_rows.loc[creative_indexes[0], "代理名称"] = "小程序-代投-贝湉"
  511. agency_rows.loc[creative_indexes[1], "代理名称"] = "小程序 -代投-贝湉"
  512. agency_rows.loc[ad_indexes[0], "代理名称"] = "小程序-代投-贝湉"
  513. original = agency_rows.copy(deep=True)
  514. with tempfile.TemporaryDirectory() as directory:
  515. outputs = write_agency_workbooks(
  516. agency_rows,
  517. Path(directory),
  518. "20260803",
  519. )
  520. self.assertEqual(
  521. [row["agency_name"] for row in outputs],
  522. ["代理B", "小程序-代投-贝湉"],
  523. )
  524. pd.testing.assert_frame_equal(agency_rows, original)
  525. bay = next(
  526. row for row in outputs if row["agency_name"] == "小程序-代投-贝湉"
  527. )
  528. self.assertEqual(bay["creative_rows"], 2)
  529. self.assertEqual(bay["ad_rows"], 1)
  530. self.assertEqual(
  531. Path(bay["report"]).name,
  532. "20260803_小程序-代投-贝湉_调控建议.xlsx",
  533. )
  534. filtered = write_agency_workbooks(
  535. agency_rows,
  536. Path(directory) / "filtered",
  537. "20260803",
  538. agency_names={"代理B"},
  539. )
  540. self.assertEqual(
  541. [row["agency_name"] for row in filtered],
  542. ["代理B"],
  543. )
  544. forbidden_fragments = (
  545. "ROI",
  546. "收入",
  547. "关停线",
  548. "扩量线",
  549. "排名",
  550. "是否位于",
  551. "t_stop",
  552. "t_up",
  553. "审批",
  554. "执行",
  555. "幂等键",
  556. )
  557. for output in outputs:
  558. workbook = load_workbook(output["report"], read_only=False)
  559. expected_sheets = {
  560. AGENCY_SUMMARY_SHEETS[ENTITY_SELF],
  561. AGENCY_SUMMARY_SHEETS[ENTITY_SELF_AD],
  562. }
  563. self.assertEqual(set(workbook.sheetnames), expected_sheets)
  564. self.assertEqual(
  565. workbook[AGENCY_SUMMARY_SHEETS[ENTITY_SELF_AD]].sheet_state,
  566. "hidden",
  567. )
  568. for sheet_name in expected_sheets:
  569. sheet = workbook[sheet_name]
  570. headers = [cell.value for cell in sheet[1]]
  571. for removed in (
  572. "包名",
  573. "广告age",
  574. "日均首层UV",
  575. "建议说明",
  576. "裂变系数-总裂变UV/T0裂变UV",
  577. "裂变系数-总裂变UV/首层UV",
  578. "日均T0裂变人数",
  579. "日均T0裂变率",
  580. ):
  581. self.assertNotIn(removed, headers)
  582. self.assertFalse(
  583. any(
  584. fragment in str(header)
  585. for header in headers
  586. for fragment in forbidden_fragments
  587. )
  588. )
  589. self.assertTrue(
  590. all(
  591. not sheet.column_dimensions[cell.column_letter].hidden
  592. for cell in sheet[1]
  593. )
  594. )
  595. creative_headers = [
  596. cell.value
  597. for cell in workbook[AGENCY_SUMMARY_SHEETS[ENTITY_SELF]][1]
  598. ]
  599. self.assertEqual(
  600. creative_headers,
  601. [
  602. "渠道",
  603. "代理名称",
  604. "账号id",
  605. "账号名称",
  606. "广告id",
  607. "广告名称",
  608. "广告优化目标",
  609. "创意id",
  610. "日均成本",
  611. "评分",
  612. "建议动作",
  613. "当前创意状态",
  614. ],
  615. )
  616. creative = workbook[AGENCY_SUMMARY_SHEETS[ENTITY_SELF]]
  617. score_column = creative_headers.index("评分") + 1
  618. self.assertEqual(
  619. creative.cell(2, score_column).number_format,
  620. "0.00",
  621. )
  622. self.assertNotIn("当日效率ROI", creative_headers)
  623. def test_summary_and_daily_report_frames(self):
  624. daily = self.build_daily()
  625. daily = pd.concat(
  626. [
  627. daily,
  628. pd.DataFrame(
  629. [
  630. row(ENTITY_SELF, "latest-only-low", DATES[-1], 0.1, uv=220),
  631. row(ENTITY_SELF, "latest-only-high", DATES[-1], 9.0, uv=280),
  632. ]
  633. ),
  634. ],
  635. ignore_index=True,
  636. )
  637. _, thresholds, summary = evaluate_rules(daily, DATES, self.ages())
  638. frame = _summary_frame(summary, ENTITY_SELF)
  639. detail = _daily_frame(summary, ENTITY_SELF, DATES)
  640. self.assertEqual(frame.iloc[-1]["阈值样本状态"], "单日补充决策_昨日UV>200")
  641. observations = frame[
  642. frame["阈值样本状态"].eq("单日补充决策_昨日UV>200")
  643. ]
  644. self.assertEqual(observations.iloc[0]["广告id"], "latest-only-low")
  645. formal_actions = frame[
  646. ~frame["阈值样本状态"].eq("单日补充决策_昨日UV>200")
  647. ]["建议动作"].tolist()
  648. action_rank = {"关停创意": 0, "扩量": 1, "观察": 2}
  649. self.assertEqual(
  650. [action_rank[action] for action in formal_actions],
  651. sorted(action_rank[action] for action in formal_actions),
  652. )
  653. self.assertIn("日均总预估效率收入", frame.columns)
  654. neutral = frame[frame["动作"].eq("")]
  655. self.assertTrue(neutral["建议动作"].eq("观察").all())
  656. self.assertTrue(
  657. neutral["建议说明"].str.contains("当前无需关停或扩量").all()
  658. )
  659. self.assertIn("日均T0裂变率", frame.columns)
  660. self.assertNotIn("三日加权平均T0裂变率", _visible_columns(SUMMARY_SHEETS[ENTITY_SELF]))
  661. self.assertIn("三日均值ROI", frame.columns)
  662. self.assertIn("当日效率ROI", frame.columns)
  663. self.assertIn("预测总效率ROI", frame.columns)
  664. visible = _visible_columns(SUMMARY_SHEETS[ENTITY_SELF])
  665. self.assertNotIn("整体三日ROI排名百分位", visible)
  666. self.assertNotIn("是否位于三日ROI后20%", visible)
  667. self.assertNotIn("最新日首层UV", visible)
  668. self.assertNotIn("覆盖天数", visible)
  669. self.assertNotIn("审批选择", visible)
  670. self.assertEqual(visible[-1], "当前创意状态")
  671. for removed in ("动作", "动作原因", "阈值样本状态", "执行状态", "执行结果"):
  672. self.assertNotIn(removed, visible)
  673. self.assertEqual(
  674. visible[visible.index("三日均值ROI") : visible.index("建议说明") + 1],
  675. [
  676. "三日均值ROI",
  677. "当日效率ROI",
  678. "预测总效率ROI",
  679. "关停线",
  680. "扩量线(P80)",
  681. "建议动作",
  682. "建议说明",
  683. ],
  684. )
  685. self.assertIn("关停线", frame.columns)
  686. self.assertNotIn("关停线分位点", frame.columns)
  687. self.assertNotIn("关停线分位点", visible)
  688. self_threshold = thresholds.set_index("entity_type").loc[ENTITY_SELF]
  689. self.assertTrue(
  690. frame["扩量线(P80)"].eq(float(self_threshold["t_up"])).all()
  691. )
  692. gzh_frame = _summary_frame(summary, ENTITY_GZH)
  693. self.assertTrue(gzh_frame["扩量线(P80)"].isna().all())
  694. self.assertIn("是否低于三日关停线", frame.columns)
  695. self.assertNotIn("关停线(P25)", frame.columns)
  696. self.assertNotIn("整体实体等权P25关停线", frame.columns)
  697. self.assertEqual(set(detail["dt"]), set(DATES))
  698. self.assertTrue((detail.groupby("广告id").size() == 3).all())
  699. self.assertEqual(detail.columns[0], "dt")
  700. self.assertTrue(detail["dt"].astype(str).is_monotonic_decreasing)
  701. self.assertIn("当日效率ROI", detail.columns)
  702. self.assertIn("预测总效率ROI", detail.columns)
  703. with tempfile.TemporaryDirectory() as directory:
  704. output = Path(directory) / "roi.xlsx"
  705. write_workbook(
  706. summary,
  707. thresholds,
  708. DATES,
  709. output,
  710. {},
  711. pd.DataFrame(
  712. [
  713. {
  714. "实体类型": "self",
  715. "匹配层级": "miniapp_package_goal_exact",
  716. "实体数": 1,
  717. "渠道实体数": 1,
  718. "匹配率": 1.0,
  719. "参数版本": "test",
  720. }
  721. ]
  722. ),
  723. )
  724. workbook = load_workbook(output, read_only=False)
  725. expected = set(SUMMARY_SHEETS.values()) | set(DAILY_SHEETS.values())
  726. self.assertTrue(expected.issubset(workbook.sheetnames))
  727. self.assertNotIn("企微群合作", workbook.sheetnames)
  728. self.assertIn("传播裂变系数匹配", workbook.sheetnames)
  729. self.assertEqual(
  730. workbook["传播裂变系数匹配"].sheet_state,
  731. "hidden",
  732. )
  733. for sheet_name in expected:
  734. self.assertEqual(workbook[sheet_name].freeze_panes, "H2")
  735. expected_rules = 3 if sheet_name in SUMMARY_SHEETS.values() else 2
  736. self.assertEqual(
  737. len(workbook[sheet_name].conditional_formatting),
  738. expected_rules,
  739. )
  740. self.assertEqual(
  741. workbook[SUMMARY_SHEETS[ENTITY_SELF_AD]].sheet_state, "hidden"
  742. )
  743. self.assertEqual(
  744. workbook[DAILY_SHEETS[ENTITY_SELF_AD]].sheet_state, "hidden"
  745. )
  746. creative_sheet = workbook[SUMMARY_SHEETS[ENTITY_SELF]]
  747. color_rules = [
  748. rule
  749. for rules in creative_sheet.conditional_formatting._cf_rules.values()
  750. for rule in rules
  751. ]
  752. self.assertEqual(len(color_rules), 3)
  753. for rule in color_rules:
  754. self.assertEqual(rule.type, "colorScale")
  755. colors = [color.rgb[-6:] for color in rule.colorScale.color]
  756. self.assertEqual(colors, ["C00000", "FFEB84", "00B050"])
  757. creative_headers = {
  758. cell.value: cell.column for cell in creative_sheet[1]
  759. }
  760. first_scale_row = next(
  761. row_number
  762. for row_number in range(2, creative_sheet.max_row + 1)
  763. if creative_sheet.cell(
  764. row_number, creative_headers["建议动作"]
  765. ).value
  766. == "扩量"
  767. )
  768. self.assertEqual(
  769. creative_sheet.cell(first_scale_row, 1).border.top.style,
  770. "medium",
  771. )
  772. self.assertEqual(
  773. creative_sheet.cell(
  774. first_scale_row, creative_headers["建议动作"]
  775. ).border.top.style,
  776. "medium",
  777. )
  778. first_observe_row = next(
  779. row_number
  780. for row_number in range(first_scale_row + 1, creative_sheet.max_row + 1)
  781. if creative_sheet.cell(
  782. row_number, creative_headers["建议动作"]
  783. ).value
  784. == "观察"
  785. )
  786. self.assertEqual(
  787. creative_sheet.cell(first_observe_row, 1).border.top.style,
  788. "medium",
  789. )
  790. self.assertEqual(
  791. creative_sheet.cell(
  792. first_observe_row, creative_headers["建议动作"]
  793. ).border.top.style,
  794. "medium",
  795. )
  796. self.assertEqual(
  797. creative_sheet.cell(2, creative_headers["日均首层UV"]).number_format,
  798. "0",
  799. )
  800. self.assertEqual(
  801. creative_sheet.cell(
  802. 2, creative_headers["日均T0裂变人数"]
  803. ).number_format,
  804. "0",
  805. )
  806. self.assertEqual(
  807. creative_sheet.cell(2, creative_headers["广告age"]).number_format,
  808. "0",
  809. )
  810. self.assertEqual(
  811. creative_sheet.cell(
  812. 2, creative_headers["三日均值ROI"]
  813. ).number_format,
  814. "0.00",
  815. )
  816. self.assertEqual(
  817. creative_sheet.cell(
  818. 2, creative_headers["当日效率ROI"]
  819. ).number_format,
  820. "0.00",
  821. )
  822. self.assertEqual(
  823. creative_sheet.cell(
  824. 2, creative_headers["预测总效率ROI"]
  825. ).number_format,
  826. "0.00",
  827. )
  828. for header in (
  829. "裂变系数-总裂变UV/T0裂变UV",
  830. "裂变系数-总裂变UV/首层UV",
  831. ):
  832. self.assertEqual(
  833. creative_sheet.cell(
  834. 2, creative_headers[header]
  835. ).number_format,
  836. "0.00",
  837. )
  838. self.assertEqual(
  839. creative_sheet.cell(
  840. 2, creative_headers["扩量线(P80)"]
  841. ).number_format,
  842. "0.00",
  843. )
  844. run_summary = workbook["运行摘要"]
  845. summary_rows = {
  846. run_summary.cell(row_number, 1).value: row_number
  847. for row_number in range(1, run_summary.max_row + 1)
  848. }
  849. self.assertEqual(
  850. run_summary.cell(summary_rows["创意扩量线(P80)"], 2).number_format,
  851. "0.00",
  852. )
  853. self.assertEqual(
  854. run_summary.cell(summary_rows["扩量样本数"], 2).number_format,
  855. "0",
  856. )
  857. self.assertEqual(
  858. run_summary.cell(
  859. summary_rows["小程序单日实际关停线"], 2
  860. ).number_format,
  861. "0.00",
  862. )
  863. self.assertEqual(
  864. run_summary.cell(
  865. summary_rows["小程序实际关停成本占比"], 2
  866. ).number_format,
  867. "0.00%",
  868. )
  869. ad_sheet = workbook[SUMMARY_SHEETS[ENTITY_SELF_AD]]
  870. ad_headers = [cell.value for cell in ad_sheet[1]]
  871. self.assertIn("审批选择", ad_headers)
  872. approval_column = ad_headers.index("审批选择") + 1
  873. self.assertTrue(
  874. ad_sheet.column_dimensions[
  875. ad_sheet.cell(1, approval_column).column_letter
  876. ].hidden
  877. )
  878. self.assertGreater(len(ad_sheet.data_validations.dataValidation), 0)
  879. def test_summary_latest_actual_roi_and_agency_score_use_latest_day(self):
  880. rows = pd.DataFrame(
  881. [
  882. row(ENTITY_SELF, "score-window", dt, roi)
  883. for dt, roi in zip(DATES, [1.0, 2.0, 3.0])
  884. ]
  885. )
  886. rows.loc[rows["dt"].eq(DATES[-1]), "裂变效率收入"] = 100.0
  887. ages = pd.DataFrame({"广告id": ["score-window"], "广告age": [10]})
  888. _, _, summary = evaluate_rules(rows, DATES, ages)
  889. internal = _summary_frame(summary, ENTITY_SELF).iloc[0]
  890. agency = _agency_summary_frame(summary, ENTITY_SELF).iloc[0]
  891. snapshot = summary.iloc[0]
  892. latest_actual = float(snapshot[f"实际ROI_{DATES[-1]}"])
  893. latest_predicted = float(snapshot[f"ROI_{DATES[-1]}"])
  894. self.assertNotAlmostEqual(latest_actual, latest_predicted)
  895. self.assertAlmostEqual(float(internal["三日均值ROI"]), float(snapshot["实际ROI"]))
  896. self.assertAlmostEqual(float(internal["当日效率ROI"]), latest_actual)
  897. self.assertAlmostEqual(float(internal["预测总效率ROI"]), float(snapshot["ROI"]))
  898. self.assertAlmostEqual(float(agency["评分"]), latest_actual)
  899. if __name__ == "__main__":
  900. unittest.main()