adjust_bid_experiment_20260729.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. #!/usr/bin/env python
  2. """一次性执行 2026-07-29 八广告分组调价实验。默认只预览。"""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import sys
  7. import uuid
  8. from dataclasses import asdict, dataclass
  9. from datetime import datetime
  10. from decimal import Decimal, ROUND_HALF_UP
  11. from pathlib import Path
  12. from typing import Any
  13. from dotenv import load_dotenv
  14. ROOT = Path(__file__).resolve().parent
  15. REPO_ROOT = ROOT.parents[1]
  16. MINIAPP_ROOT = ROOT.parent / "auto_put_ad_mini"
  17. for path in (ROOT, MINIAPP_ROOT, REPO_ROOT):
  18. if str(path) not in sys.path:
  19. sys.path.insert(0, str(path))
  20. load_dotenv(MINIAPP_ROOT / ".env", override=False)
  21. load_dotenv(REPO_ROOT / ".env", override=False)
  22. from run_once import SHANGHAI # noqa: E402
  23. from storage import ( # noqa: E402
  24. advisory_lock,
  25. clear_bid_experiment_hold,
  26. initialize_schema,
  27. insert_action_log,
  28. set_bid_experiment_hold,
  29. upsert_realtime_account_scope,
  30. )
  31. from tencent_client import ( # noqa: E402
  32. ACTIVE_STATUS,
  33. TencentClient,
  34. current_bid_fen,
  35. resolve_bid_field,
  36. )
  37. @dataclass(frozen=True)
  38. class ExperimentAd:
  39. group: str
  40. factor: Decimal
  41. agency: str
  42. audience_name: str
  43. account_id: int
  44. adgroup_id: int
  45. adgroup_name: str
  46. hold_realtime_bid: bool = False
  47. EXPERIMENT_ADS = (
  48. ExperimentAd("下调10%", Decimal("0.90"), "小程序-代投-像素", "R50*已转化", 84207102, 107115658070, "R50*已转化-含小程序"),
  49. ExperimentAd("下调10%", Decimal("0.90"), "小程序-自动化", "泛人群", 86748335, 117650209221, "泛人群-20260716-关键页面-targeted", True),
  50. ExperimentAd("下调5%", Decimal("0.95"), "小程序-代投-像素", "", 82037573, 116560437525, "泛人群-公小朋-45+-0713"),
  51. ExperimentAd("下调5%", Decimal("0.95"), "小程序-代投-翱鲨", "回流330以上人群", 86532580, 118337357855, "票圈-图片-朋友圈+公小-0719-zc-R330人群-关键页"),
  52. ExperimentAd("上调5%", Decimal("1.05"), "小程序-代投-棱镜", "R50*泛知识*时政历史", 79911661, 108000527445, "0612-朋友圈+公众号小程序-关键页r50历史-玉"),
  53. ExperimentAd("上调5%", Decimal("1.05"), "小程序-自动化", "回流330以上人群", 86197371, 115272976405, "回流330以上人群-20260708-关键页面-targeted", True),
  54. ExperimentAd("上调10%", Decimal("1.10"), "小程序-代投-翱鲨", "R50*泛知识*生活科普", 83290935, 108653638689, "票圈-图片-朋友圈+公小-0615-zc-R50*泛知识*生活科普-关键页-2"),
  55. ExperimentAd("上调10%", Decimal("1.10"), "小程序-代投-棱镜", "wx*商业", 85504853, 119352748609, "0722朋友圈+公众号小程序-wx*商业*玉-点击"),
  56. )
  57. def target_bid_fen(current_bid: int, factor: Decimal) -> int:
  58. return int(
  59. (Decimal(current_bid) * factor).quantize(
  60. Decimal("1"), rounding=ROUND_HALF_UP
  61. )
  62. )
  63. def prepare(client: TencentClient) -> list[dict[str, Any]]:
  64. rows: list[dict[str, Any]] = []
  65. for spec in EXPERIMENT_ADS:
  66. ad = client.get_ad(spec.account_id, spec.adgroup_id)
  67. actual_name = str(ad.get("adgroup_name") or "").strip()
  68. if actual_name != spec.adgroup_name:
  69. raise ValueError(
  70. f"广告名称不一致: account={spec.account_id} "
  71. f"adgroup={spec.adgroup_id} actual={actual_name!r}"
  72. )
  73. status = str(ad.get("configured_status") or "")
  74. if status != ACTIVE_STATUS:
  75. raise ValueError(
  76. f"广告当前不是正常状态: account={spec.account_id} "
  77. f"adgroup={spec.adgroup_id} status={status}"
  78. )
  79. bid_field = resolve_bid_field(ad, None)
  80. current_bid = current_bid_fen(ad, bid_field)
  81. if current_bid is None or current_bid <= 0:
  82. raise ValueError(
  83. f"广告当前出价无效: account={spec.account_id} "
  84. f"adgroup={spec.adgroup_id} field={bid_field}"
  85. )
  86. rows.append(
  87. {
  88. **asdict(spec),
  89. "factor": str(spec.factor),
  90. "configured_status": status,
  91. "bid_field": bid_field,
  92. "current_bid_fen": current_bid,
  93. "target_bid_fen": target_bid_fen(current_bid, spec.factor),
  94. "status": "planned",
  95. }
  96. )
  97. return rows
  98. def write_report(rows: list[dict[str, Any]], mode: str) -> Path:
  99. output_dir = ROOT / "outputs"
  100. output_dir.mkdir(parents=True, exist_ok=True)
  101. timestamp = datetime.now(SHANGHAI).strftime("%Y%m%d_%H%M%S")
  102. path = output_dir / f"bid_experiment_20260729_{mode}_{timestamp}.json"
  103. path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
  104. return path
  105. def release_holds(apply: bool) -> int:
  106. now = datetime.now(SHANGHAI)
  107. rows = [
  108. {
  109. "account_id": spec.account_id,
  110. "adgroup_id": spec.adgroup_id,
  111. "adgroup_name": spec.adgroup_name,
  112. "status": "planned",
  113. }
  114. for spec in EXPERIMENT_ADS
  115. if spec.hold_realtime_bid
  116. ]
  117. if apply:
  118. with advisory_lock("tencent_realtime_control") as acquired:
  119. if not acquired:
  120. raise RuntimeError("实时调控正在执行,未获得数据库锁")
  121. for row in rows:
  122. clear_bid_experiment_hold(
  123. int(row["account_id"]),
  124. int(row["adgroup_id"]),
  125. action_at=now,
  126. )
  127. row["status"] = "success"
  128. report = write_report(rows, "release_apply" if apply else "release_dry_run")
  129. print(json.dumps({"rows": rows, "report": str(report)}, ensure_ascii=False))
  130. return 0
  131. def register_pause_only_scope(apply: bool) -> int:
  132. rows = [
  133. {
  134. "account_id": spec.account_id,
  135. "audience_name": spec.audience_name,
  136. "control_mode": "PAUSE_ONLY",
  137. "status": "planned",
  138. }
  139. for spec in EXPERIMENT_ADS
  140. ]
  141. if apply:
  142. for row in rows:
  143. upsert_realtime_account_scope(
  144. account_id=int(row["account_id"]),
  145. control_mode="PAUSE_ONLY",
  146. audience_name=str(row["audience_name"]),
  147. bid_scene=None,
  148. source="bid_experiment_20260729",
  149. note="八账户只参与实时CPM关停",
  150. )
  151. row["status"] = "success"
  152. report = write_report(
  153. rows,
  154. "scope_apply" if apply else "scope_dry_run",
  155. )
  156. print(json.dumps({"rows": rows, "report": str(report)}, ensure_ascii=False))
  157. return 0
  158. def apply_experiment(client: TencentClient, rows: list[dict[str, Any]]) -> int:
  159. run_id = f"bid-exp-20260729-{uuid.uuid4().hex[:12]}"
  160. now = datetime.now(SHANGHAI)
  161. failures = 0
  162. with advisory_lock("tencent_realtime_control") as acquired:
  163. if not acquired:
  164. raise RuntimeError("实时调控正在执行,未获得数据库锁")
  165. for row in rows:
  166. before = int(row["current_bid_fen"])
  167. target = int(row["target_bid_fen"])
  168. try:
  169. readback = client.update_ad(
  170. int(row["account_id"]),
  171. int(row["adgroup_id"]),
  172. bid_field=str(row["bid_field"]),
  173. target_bid_fen=target,
  174. )
  175. row["readback_bid_fen"] = int(readback[row["bid_field"]])
  176. if row["hold_realtime_bid"]:
  177. try:
  178. set_bid_experiment_hold(
  179. account_id=int(row["account_id"]),
  180. adgroup_id=int(row["adgroup_id"]),
  181. adgroup_name=str(row["adgroup_name"]),
  182. bid_field=str(row["bid_field"]),
  183. base_bid_fen=target,
  184. action_at=now,
  185. reason="20260729分组调价实验_手动解除",
  186. )
  187. except Exception:
  188. client.update_ad(
  189. int(row["account_id"]),
  190. int(row["adgroup_id"]),
  191. bid_field=str(row["bid_field"]),
  192. target_bid_fen=before,
  193. )
  194. raise
  195. row["status"] = "success"
  196. except Exception as exc:
  197. failures += 1
  198. row["status"] = "failed"
  199. row["error"] = str(exc)
  200. try:
  201. insert_action_log(
  202. {
  203. "run_id": run_id,
  204. "control_date": now.date(),
  205. "decision": "BID_EXPERIMENT",
  206. "account_id": row["account_id"],
  207. "adgroup_id": row["adgroup_id"],
  208. "adgroup_name": row["adgroup_name"],
  209. "bid_field": row["bid_field"],
  210. "base_bid_fen": row["target_bid_fen"],
  211. "before_bid_fen": row["current_bid_fen"],
  212. "target_bid_fen": row["target_bid_fen"],
  213. "before_status": row["configured_status"],
  214. "apply_mode": True,
  215. "execution_status": row["status"],
  216. "error_message": row.get("error"),
  217. }
  218. )
  219. except Exception as exc:
  220. row["audit_error"] = str(exc)
  221. report = write_report(rows, "apply")
  222. print(
  223. json.dumps(
  224. {"run_id": run_id, "failures": failures, "report": str(report)},
  225. ensure_ascii=False,
  226. )
  227. )
  228. return 1 if failures else 0
  229. def main() -> int:
  230. parser = argparse.ArgumentParser(description="执行20260729八广告分组调价实验")
  231. parser.add_argument("--apply", action="store_true", help="真实修改腾讯出价")
  232. parser.add_argument(
  233. "--release-hold",
  234. action="store_true",
  235. help="只解除两条自动化广告的实时出价冻结,不修改腾讯价格",
  236. )
  237. parser.add_argument(
  238. "--register-pause-only",
  239. action="store_true",
  240. help="只把八个账户登记为实时CPM关停范围,不再次调价",
  241. )
  242. args = parser.parse_args()
  243. initialize_schema()
  244. if args.release_hold:
  245. return release_holds(args.apply)
  246. if args.register_pause_only:
  247. return register_pause_only_scope(args.apply)
  248. client = TencentClient()
  249. rows = prepare(client)
  250. if args.apply:
  251. return apply_experiment(client, rows)
  252. report = write_report(rows, "dry_run")
  253. print(json.dumps({"rows": rows, "report": str(report)}, ensure_ascii=False))
  254. return 0
  255. if __name__ == "__main__":
  256. raise SystemExit(main())