run_once.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. #!/usr/bin/env python
  2. """Run one idempotent real-time CPM control cycle."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import logging
  7. import time
  8. import uuid
  9. from datetime import datetime, timedelta
  10. from decimal import Decimal, ROUND_HALF_UP
  11. from pathlib import Path
  12. from typing import Any
  13. from zoneinfo import ZoneInfo
  14. from dotenv import load_dotenv
  15. ROOT = Path(__file__).resolve().parent
  16. SHANGHAI = ZoneInfo("Asia/Shanghai")
  17. ACTIVE_STATUS = "AD_STATUS_NORMAL"
  18. SUSPEND_STATUS = "AD_STATUS_SUSPEND"
  19. DECISION_BOOST = "BOOST"
  20. DECISION_RESTORE = "RESTORE"
  21. DECISION_PAUSE = "PAUSE"
  22. DECISION_CUTOFF = "CUTOFF"
  23. DECISION_OFF_HOURS = "OFF_HOURS"
  24. DECISION_WAIT_DATA = "WAIT_DATA"
  25. logger = logging.getLogger("tencent_realtime_control")
  26. def load_environment() -> None:
  27. load_dotenv(ROOT.parent / "auto_put_ad_mini" / ".env", override=False)
  28. load_dotenv(Path.cwd() / ".env", override=False)
  29. def decide(cpm: Decimal, low_cpm: Decimal, high_cpm: Decimal) -> str:
  30. if cpm > high_cpm:
  31. return DECISION_BOOST
  32. if cpm < low_cpm:
  33. return DECISION_PAUSE
  34. return DECISION_RESTORE
  35. def boosted_bid(base_bid_fen: int, ratio: Decimal) -> int:
  36. return int(
  37. (Decimal(base_bid_fen) * ratio).quantize(
  38. Decimal("1"), rounding=ROUND_HALF_UP
  39. )
  40. )
  41. def partition_lag_hours(partition: str, now: datetime) -> float:
  42. try:
  43. partition_time = datetime.strptime(partition[:10], "%Y%m%d%H").replace(
  44. tzinfo=SHANGHAI
  45. )
  46. except (TypeError, ValueError) as exc:
  47. raise ValueError(f"Invalid CPM partition: {partition!r}") from exc
  48. return (now - partition_time).total_seconds() / 3600
  49. def should_refresh_inventory(
  50. daily_state: dict[str, Any],
  51. *,
  52. observed_partition: str,
  53. decision: str,
  54. now: datetime,
  55. refresh_minutes: int,
  56. ) -> bool:
  57. if (
  58. daily_state.get("last_observed_partition") != observed_partition
  59. or daily_state.get("last_decision") != decision
  60. ):
  61. return True
  62. refreshed_at = daily_state.get("last_inventory_refresh_at")
  63. if not refreshed_at:
  64. return True
  65. if refreshed_at.tzinfo is None:
  66. refreshed_at = refreshed_at.replace(tzinfo=SHANGHAI)
  67. return now - refreshed_at >= timedelta(minutes=refresh_minutes)
  68. def target_for_ad(
  69. *,
  70. decision: str,
  71. control_date: Any,
  72. current_status: str,
  73. current_bid: int,
  74. base_bid: int,
  75. boosted_date: Any,
  76. paused_by_strategy: bool,
  77. bid_up_ratio: Decimal,
  78. ) -> dict[str, Any] | None:
  79. manual_suspend = current_status != ACTIVE_STATUS and not paused_by_strategy
  80. target_bid = base_bid
  81. target_status: str | None = None
  82. next_boosted_date = boosted_date
  83. next_paused = False
  84. pause_reason = None
  85. limit_reached = False
  86. if manual_suspend:
  87. if decision not in (DECISION_PAUSE, DECISION_CUTOFF):
  88. return None
  89. return {
  90. "target_bid_fen": base_bid,
  91. "target_status": None,
  92. "boosted_date": boosted_date,
  93. "paused_by_strategy": False,
  94. "pause_reason": None,
  95. "boost_limit_reached": False,
  96. "bid_change_needed": current_bid != base_bid,
  97. "status_change_needed": False,
  98. }
  99. if decision == DECISION_BOOST:
  100. if boosted_date == control_date:
  101. limit_reached = True
  102. raised_bid = boosted_bid(base_bid, bid_up_ratio)
  103. if current_bid == raised_bid:
  104. target_bid = raised_bid
  105. else:
  106. target_bid = boosted_bid(base_bid, bid_up_ratio)
  107. next_boosted_date = control_date
  108. if paused_by_strategy:
  109. target_status = ACTIVE_STATUS
  110. elif decision == DECISION_RESTORE:
  111. if paused_by_strategy:
  112. target_status = ACTIVE_STATUS
  113. elif decision in (DECISION_PAUSE, DECISION_CUTOFF):
  114. target_status = SUSPEND_STATUS if current_status != SUSPEND_STATUS else None
  115. next_paused = True
  116. pause_reason = "low_cpm" if decision == DECISION_PAUSE else "cutoff"
  117. else:
  118. raise ValueError(f"Unsupported decision: {decision}")
  119. return {
  120. "target_bid_fen": target_bid,
  121. "target_status": target_status,
  122. "boosted_date": next_boosted_date,
  123. "paused_by_strategy": next_paused,
  124. "pause_reason": pause_reason,
  125. "boost_limit_reached": limit_reached,
  126. "bid_change_needed": current_bid != target_bid,
  127. "status_change_needed": target_status is not None
  128. and target_status != current_status,
  129. }
  130. def write_run_report(payload: dict[str, Any]) -> Path:
  131. output_dir = ROOT / "outputs"
  132. output_dir.mkdir(parents=True, exist_ok=True)
  133. timestamp = datetime.now(SHANGHAI).strftime("%Y%m%d_%H%M%S")
  134. mode = "apply" if payload["apply"] else "dry_run"
  135. path = output_dir / f"control_{mode}_{timestamp}.json"
  136. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  137. return path
  138. def execute_inventory_action(
  139. *,
  140. run_id: str,
  141. now: datetime,
  142. decision: str,
  143. observed_partition: str | None,
  144. observed_cpm: Decimal | None,
  145. apply: bool,
  146. accounts: list[dict[str, Any]],
  147. config: Any,
  148. tencent: Any,
  149. test_notification: bool = False,
  150. ) -> dict[str, Any]:
  151. from storage import insert_action_log, load_ad_states, upsert_ad_state
  152. from tencent_client import current_bid_fen, resolve_bid_field
  153. results: list[dict[str, Any]] = []
  154. failures = 0
  155. api_updates = 0
  156. for account in accounts:
  157. account_id = int(account["account_id"])
  158. states = load_ad_states(account_id)
  159. try:
  160. ads = tencent.get_ads(account_id)
  161. except Exception as exc:
  162. failures += 1
  163. results.append(
  164. {
  165. "account_id": account_id,
  166. "status": "account_fetch_failed",
  167. "error": str(exc),
  168. }
  169. )
  170. continue
  171. for ad in ads:
  172. adgroup_id = int(ad.get("adgroup_id") or 0)
  173. current_status = str(ad.get("configured_status") or "")
  174. state = states.get(adgroup_id)
  175. paused_by_strategy = bool(
  176. (state or {}).get("paused_by_strategy")
  177. )
  178. if (
  179. current_status != ACTIVE_STATUS
  180. and not paused_by_strategy
  181. and (
  182. state is None
  183. or decision not in (DECISION_PAUSE, DECISION_CUTOFF)
  184. )
  185. ):
  186. continue
  187. bid_field = (
  188. str(state["bid_field"])
  189. if state
  190. else resolve_bid_field(ad, account.get("bid_scene"))
  191. )
  192. current_bid = current_bid_fen(ad, bid_field)
  193. if current_bid is None or current_bid <= 0:
  194. failures += 1
  195. results.append(
  196. {
  197. "account_id": account_id,
  198. "adgroup_id": adgroup_id,
  199. "status": "invalid_current_bid",
  200. "bid_field": bid_field,
  201. }
  202. )
  203. continue
  204. base_bid = int(state["base_bid_fen"]) if state else current_bid
  205. plan = target_for_ad(
  206. decision=decision,
  207. control_date=now.date(),
  208. current_status=current_status,
  209. current_bid=current_bid,
  210. base_bid=base_bid,
  211. boosted_date=(state or {}).get("boosted_date"),
  212. paused_by_strategy=paused_by_strategy,
  213. bid_up_ratio=config.bid_up_ratio,
  214. )
  215. if plan is None:
  216. continue
  217. needs_api = plan["bid_change_needed"] or plan["status_change_needed"]
  218. execution_status = "planned" if needs_api else "noop"
  219. error = None
  220. if apply and state is None:
  221. upsert_ad_state(
  222. account_id=account_id,
  223. adgroup_id=adgroup_id,
  224. adgroup_name=str(ad.get("adgroup_name") or ""),
  225. bid_field=bid_field,
  226. base_bid_fen=base_bid,
  227. boosted_date=None,
  228. paused_by_strategy=decision
  229. in (DECISION_PAUSE, DECISION_CUTOFF),
  230. pause_reason=plan["pause_reason"],
  231. last_action="REGISTER_BASE",
  232. action_at=now,
  233. )
  234. elif (
  235. apply
  236. and decision in (DECISION_PAUSE, DECISION_CUTOFF)
  237. and plan["paused_by_strategy"]
  238. ):
  239. upsert_ad_state(
  240. account_id=account_id,
  241. adgroup_id=adgroup_id,
  242. adgroup_name=str(ad.get("adgroup_name") or ""),
  243. bid_field=bid_field,
  244. base_bid_fen=base_bid,
  245. boosted_date=(state or {}).get("boosted_date"),
  246. paused_by_strategy=True,
  247. pause_reason=plan["pause_reason"],
  248. last_action=f"{decision}_PENDING",
  249. action_at=now,
  250. )
  251. if apply and needs_api:
  252. try:
  253. tencent.update_ad(
  254. account_id,
  255. adgroup_id,
  256. bid_field=bid_field
  257. if plan["bid_change_needed"]
  258. else None,
  259. target_bid_fen=plan["target_bid_fen"]
  260. if plan["bid_change_needed"]
  261. else None,
  262. target_status=plan["target_status"]
  263. if plan["status_change_needed"]
  264. else None,
  265. )
  266. execution_status = "success"
  267. api_updates += 1
  268. time.sleep(0.15)
  269. except Exception as exc:
  270. execution_status = "failed"
  271. error = str(exc)
  272. failures += 1
  273. result = {
  274. "account_id": account_id,
  275. "audience_name": account.get("audience_name"),
  276. "adgroup_id": adgroup_id,
  277. "adgroup_name": ad.get("adgroup_name"),
  278. "decision": decision,
  279. "bid_field": bid_field,
  280. "base_bid_fen": base_bid,
  281. "before_bid_fen": current_bid,
  282. "target_bid_fen": plan["target_bid_fen"],
  283. "before_status": current_status,
  284. "target_status": plan["target_status"],
  285. "boost_limit_reached": plan["boost_limit_reached"],
  286. "bid_change_needed": plan["bid_change_needed"],
  287. "status_change_needed": plan["status_change_needed"],
  288. "status": execution_status,
  289. "error": error,
  290. }
  291. results.append(result)
  292. if apply and execution_status != "failed":
  293. upsert_ad_state(
  294. account_id=account_id,
  295. adgroup_id=adgroup_id,
  296. adgroup_name=str(ad.get("adgroup_name") or ""),
  297. bid_field=bid_field,
  298. base_bid_fen=base_bid,
  299. boosted_date=plan["boosted_date"],
  300. paused_by_strategy=plan["paused_by_strategy"],
  301. pause_reason=plan["pause_reason"],
  302. last_action=decision,
  303. action_at=now,
  304. )
  305. if apply and (needs_api or state is None or execution_status == "failed"):
  306. insert_action_log(
  307. {
  308. "run_id": run_id,
  309. "control_date": now.date(),
  310. "observed_partition": observed_partition,
  311. "observed_cpm": observed_cpm,
  312. "decision": decision,
  313. "account_id": account_id,
  314. "adgroup_id": adgroup_id,
  315. "adgroup_name": ad.get("adgroup_name"),
  316. "bid_field": bid_field,
  317. "base_bid_fen": base_bid,
  318. "before_bid_fen": current_bid,
  319. "target_bid_fen": plan["target_bid_fen"],
  320. "before_status": current_status,
  321. "target_status": plan["target_status"],
  322. "apply_mode": True,
  323. "execution_status": execution_status,
  324. "error_message": error,
  325. }
  326. )
  327. notification = {"status": "dry_run" if not apply else "no_actions"}
  328. if apply or test_notification:
  329. from feishu_notifier import send_action_notification
  330. successful_actions: dict[int, list[dict[str, Any]]] = {}
  331. expected_status = "success" if apply else "planned"
  332. for result in results:
  333. if result.get("status") != expected_status:
  334. continue
  335. if not (
  336. result.get("bid_change_needed")
  337. or (
  338. result.get("decision") in (DECISION_PAUSE, DECISION_CUTOFF)
  339. and result.get("status_change_needed")
  340. )
  341. ):
  342. continue
  343. successful_actions.setdefault(int(result["account_id"]), []).append(
  344. result
  345. )
  346. for account_id, account_results in successful_actions.items():
  347. try:
  348. metrics = tencent.get_today_ad_metrics(
  349. account_id,
  350. [int(row["adgroup_id"]) for row in account_results],
  351. now.date(),
  352. )
  353. for result in account_results:
  354. ad_metrics = metrics.get(int(result["adgroup_id"]), {})
  355. result["today_cost_fen"] = int(
  356. ad_metrics.get("cost_fen") or 0
  357. )
  358. result["today_impressions"] = int(
  359. ad_metrics.get("impressions") or 0
  360. )
  361. result["today_clicks"] = int(
  362. ad_metrics.get("clicks") or 0
  363. )
  364. except Exception as exc:
  365. logger.warning(
  366. "Failed to fetch today's metrics account=%s: %s",
  367. account_id,
  368. exc,
  369. )
  370. notification = send_action_notification(
  371. run_id=run_id,
  372. now=now,
  373. results=results,
  374. bid_up_ratio=config.bid_up_ratio,
  375. decision=decision,
  376. observed_partition=observed_partition,
  377. observed_cpm=observed_cpm,
  378. test_mode=test_notification,
  379. )
  380. return {
  381. "failures": failures,
  382. "api_updates": api_updates,
  383. "results": results,
  384. "notification": notification,
  385. }
  386. def run_cycle(
  387. *,
  388. now: datetime,
  389. apply: bool,
  390. cpm_override: Decimal | None = None,
  391. force_refresh: bool = False,
  392. test_notification: bool = False,
  393. ) -> dict[str, Any]:
  394. from odps_source import HourlyCpm, build_odps_client, fetch_latest_cpm
  395. from realtime_config import RealtimeControlConfig
  396. from storage import (
  397. advisory_lock,
  398. load_daily_state,
  399. load_enabled_accounts,
  400. save_daily_state,
  401. )
  402. from tencent_client import TencentClient
  403. config = RealtimeControlConfig.from_env()
  404. run_id = str(uuid.uuid4())
  405. payload: dict[str, Any] = {
  406. "run_id": run_id,
  407. "now": now.isoformat(),
  408. "apply": apply,
  409. "decision": None,
  410. "observed_partition": None,
  411. "observed_cpm": None,
  412. "summary": {},
  413. }
  414. with advisory_lock(config.lock_name) as acquired:
  415. if not acquired:
  416. payload["decision"] = "LOCKED_BY_OTHER_WORKER"
  417. return payload
  418. daily_state = load_daily_state(now.date())
  419. accounts = load_enabled_accounts()
  420. if now.hour < config.start_hour:
  421. payload["decision"] = DECISION_OFF_HOURS
  422. return payload
  423. if now.hour >= config.stop_hour:
  424. payload["decision"] = DECISION_CUTOFF
  425. if apply and bool(daily_state.get("cutoff_done")) and not force_refresh:
  426. payload["summary"] = {"status": "cutoff_already_done"}
  427. return payload
  428. execution = execute_inventory_action(
  429. run_id=run_id,
  430. now=now,
  431. decision=DECISION_CUTOFF,
  432. observed_partition=None,
  433. observed_cpm=None,
  434. apply=apply,
  435. accounts=accounts,
  436. config=config,
  437. tencent=TencentClient(),
  438. test_notification=test_notification,
  439. )
  440. payload["summary"] = execution
  441. if apply and not execution["failures"]:
  442. save_daily_state(
  443. now.date(),
  444. cutoff_done=True,
  445. last_decision=DECISION_CUTOFF,
  446. last_inventory_refresh_at=now,
  447. last_evaluated_at=now,
  448. )
  449. return payload
  450. if apply and not bool(daily_state.get("morning_recovery_done")):
  451. execution = execute_inventory_action(
  452. run_id=run_id,
  453. now=now,
  454. decision=DECISION_RESTORE,
  455. observed_partition=None,
  456. observed_cpm=None,
  457. apply=True,
  458. accounts=accounts,
  459. config=config,
  460. tencent=TencentClient(),
  461. test_notification=test_notification,
  462. )
  463. payload["decision"] = "MORNING_RECOVERY"
  464. payload["summary"] = execution
  465. if not execution["failures"]:
  466. save_daily_state(
  467. now.date(),
  468. morning_recovery_done=True,
  469. last_inventory_refresh_at=now,
  470. last_evaluated_at=now,
  471. )
  472. return payload
  473. if cpm_override is not None:
  474. signal = HourlyCpm(
  475. partition=f"{now.strftime('%Y%m%d%H')}_override",
  476. hour_of_day=now.hour,
  477. impressions=0,
  478. cpm=float(cpm_override),
  479. )
  480. else:
  481. signal = fetch_latest_cpm(build_odps_client(), now.date())
  482. earliest_signal_hour = max(0, config.start_hour - 1)
  483. if signal is None or signal.hour_of_day < earliest_signal_hour:
  484. payload["decision"] = DECISION_WAIT_DATA
  485. payload["summary"] = {
  486. "reason": (
  487. "No same-day CPM partition at or after "
  488. f"{earliest_signal_hour:02d}:00"
  489. )
  490. }
  491. return payload
  492. observed_cpm = Decimal(str(signal.cpm))
  493. lag_hours = partition_lag_hours(signal.partition, now)
  494. payload.update(
  495. {
  496. "observed_partition": signal.partition,
  497. "observed_cpm": float(observed_cpm),
  498. "impressions": signal.impressions,
  499. "partition_lag_hours": round(lag_hours, 4),
  500. }
  501. )
  502. if lag_hours < 0 or lag_hours > config.max_partition_lag_hours:
  503. payload["decision"] = DECISION_WAIT_DATA
  504. payload["summary"] = {
  505. "reason": (
  506. f"CPM partition lag {lag_hours:.2f}h exceeds allowed "
  507. f"{config.max_partition_lag_hours}h"
  508. )
  509. }
  510. return payload
  511. decision = decide(observed_cpm, config.low_cpm, config.high_cpm)
  512. payload.update(
  513. {
  514. "decision": decision,
  515. }
  516. )
  517. refresh = force_refresh or should_refresh_inventory(
  518. daily_state,
  519. observed_partition=signal.partition,
  520. decision=decision,
  521. now=now,
  522. refresh_minutes=config.inventory_refresh_minutes,
  523. )
  524. if apply and not refresh:
  525. save_daily_state(now.date(), last_evaluated_at=now)
  526. payload["summary"] = {"status": "same_signal_noop"}
  527. return payload
  528. execution = execute_inventory_action(
  529. run_id=run_id,
  530. now=now,
  531. decision=decision,
  532. observed_partition=signal.partition,
  533. observed_cpm=observed_cpm,
  534. apply=apply,
  535. accounts=accounts,
  536. config=config,
  537. tencent=TencentClient(),
  538. test_notification=test_notification,
  539. )
  540. payload["summary"] = execution
  541. if apply and not execution["failures"]:
  542. save_daily_state(
  543. now.date(),
  544. last_observed_partition=signal.partition,
  545. last_observed_cpm=observed_cpm,
  546. last_decision=decision,
  547. last_inventory_refresh_at=now,
  548. last_evaluated_at=now,
  549. )
  550. return payload
  551. def parse_args() -> argparse.Namespace:
  552. parser = argparse.ArgumentParser(description=__doc__)
  553. parser.add_argument(
  554. "--apply",
  555. action="store_true",
  556. help="Execute Tencent updates. Default is dry-run.",
  557. )
  558. parser.add_argument(
  559. "--at",
  560. help="Dry-run only: simulate Shanghai local time, ISO format.",
  561. )
  562. parser.add_argument(
  563. "--cpm-override",
  564. type=Decimal,
  565. help="Dry-run only: override latest CPM to validate decision branches.",
  566. )
  567. parser.add_argument("--force-refresh", action="store_true")
  568. parser.add_argument(
  569. "--test-notification",
  570. action="store_true",
  571. help=(
  572. "Dry-run only: use real inventory and metrics, then send a clearly "
  573. "marked simulated Feishu action sheet."
  574. ),
  575. )
  576. return parser.parse_args()
  577. def main() -> int:
  578. load_environment()
  579. args = parse_args()
  580. if args.apply and (args.at or args.cpm_override is not None):
  581. raise ValueError("--at and --cpm-override are forbidden with --apply")
  582. if args.apply and args.test_notification:
  583. raise ValueError("--test-notification is forbidden with --apply")
  584. now = (
  585. datetime.fromisoformat(args.at).replace(tzinfo=SHANGHAI)
  586. if args.at
  587. else datetime.now(SHANGHAI)
  588. )
  589. payload = run_cycle(
  590. now=now,
  591. apply=args.apply,
  592. cpm_override=args.cpm_override,
  593. force_refresh=args.force_refresh,
  594. test_notification=args.test_notification,
  595. )
  596. report = write_run_report(payload)
  597. compact = {
  598. "decision": payload["decision"],
  599. "observed_partition": payload.get("observed_partition"),
  600. "observed_cpm": payload.get("observed_cpm"),
  601. "apply": payload["apply"],
  602. "api_updates": payload.get("summary", {}).get("api_updates", 0),
  603. "failures": payload.get("summary", {}).get("failures", 0),
  604. "notification": payload.get("summary", {}).get("notification", {}).get(
  605. "status"
  606. ),
  607. "report": str(report),
  608. }
  609. print(json.dumps(compact, ensure_ascii=False))
  610. return 1 if compact["failures"] else 0
  611. if __name__ == "__main__":
  612. logging.basicConfig(
  613. level=logging.INFO,
  614. format="%(asctime)s %(levelname)s %(name)s %(message)s",
  615. )
  616. try:
  617. raise SystemExit(main())
  618. except Exception as exc:
  619. logger.exception("Real-time control failed: %s", exc)
  620. raise SystemExit(1)