run_once.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  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. operator_pause_expired: bool,
  78. bid_up_ratio: Decimal,
  79. ) -> dict[str, Any] | None:
  80. managed_suspend = paused_by_strategy or operator_pause_expired
  81. manual_suspend = current_status != ACTIVE_STATUS and not managed_suspend
  82. target_bid = base_bid
  83. target_status: str | None = None
  84. next_boosted_date = boosted_date
  85. next_paused = False
  86. pause_reason = None
  87. limit_reached = False
  88. defer_to_next_day = False
  89. if manual_suspend:
  90. if decision not in (DECISION_PAUSE, DECISION_CUTOFF):
  91. return None
  92. return {
  93. "target_bid_fen": base_bid,
  94. "target_status": None,
  95. "boosted_date": boosted_date,
  96. "paused_by_strategy": False,
  97. "pause_reason": None,
  98. "boost_limit_reached": False,
  99. "bid_change_needed": current_bid != base_bid,
  100. "status_change_needed": False,
  101. "defer_to_next_day": False,
  102. }
  103. if decision == DECISION_BOOST:
  104. if boosted_date == control_date:
  105. limit_reached = True
  106. raised_bid = boosted_bid(base_bid, bid_up_ratio)
  107. if current_bid == raised_bid:
  108. target_bid = raised_bid
  109. else:
  110. target_bid = boosted_bid(base_bid, bid_up_ratio)
  111. next_boosted_date = control_date
  112. if managed_suspend:
  113. target_status = ACTIVE_STATUS
  114. elif decision == DECISION_RESTORE:
  115. if managed_suspend:
  116. target_status = ACTIVE_STATUS
  117. elif decision == DECISION_PAUSE:
  118. if managed_suspend:
  119. target_status = ACTIVE_STATUS
  120. defer_to_next_day = True
  121. elif decision == DECISION_CUTOFF:
  122. target_status = SUSPEND_STATUS if current_status != SUSPEND_STATUS else None
  123. next_paused = True
  124. pause_reason = "cutoff"
  125. else:
  126. raise ValueError(f"Unsupported decision: {decision}")
  127. return {
  128. "target_bid_fen": target_bid,
  129. "target_status": target_status,
  130. "boosted_date": next_boosted_date,
  131. "paused_by_strategy": next_paused,
  132. "pause_reason": pause_reason,
  133. "boost_limit_reached": limit_reached,
  134. "bid_change_needed": current_bid != target_bid,
  135. "status_change_needed": target_status is not None
  136. and target_status != current_status,
  137. "defer_to_next_day": defer_to_next_day,
  138. }
  139. def write_run_report(payload: dict[str, Any]) -> Path:
  140. output_dir = ROOT / "outputs"
  141. output_dir.mkdir(parents=True, exist_ok=True)
  142. timestamp = datetime.now(SHANGHAI).strftime("%Y%m%d_%H%M%S")
  143. mode = "apply" if payload["apply"] else "dry_run"
  144. path = output_dir / f"control_{mode}_{timestamp}.json"
  145. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  146. return path
  147. def execute_inventory_action(
  148. *,
  149. run_id: str,
  150. now: datetime,
  151. decision: str,
  152. observed_partition: str | None,
  153. observed_cpm: Decimal | None,
  154. apply: bool,
  155. accounts: list[dict[str, Any]],
  156. config: Any,
  157. tencent: Any,
  158. test_notification: bool = False,
  159. ) -> dict[str, Any]:
  160. from storage import (
  161. clear_operator_pause,
  162. insert_action_log,
  163. load_ad_states,
  164. upsert_ad_state,
  165. )
  166. from tencent_client import current_bid_fen, resolve_bid_field
  167. results: list[dict[str, Any]] = []
  168. failures = 0
  169. api_updates = 0
  170. for account in accounts:
  171. account_id = int(account["account_id"])
  172. control_mode = str(account.get("control_mode") or "FULL").upper()
  173. if control_mode == "PAUSE_ONLY" and decision not in (
  174. DECISION_PAUSE,
  175. DECISION_CUTOFF,
  176. ):
  177. continue
  178. states = load_ad_states(account_id)
  179. try:
  180. ads = tencent.get_ads(account_id)
  181. except Exception as exc:
  182. failures += 1
  183. results.append(
  184. {
  185. "account_id": account_id,
  186. "status": "account_fetch_failed",
  187. "error": str(exc),
  188. }
  189. )
  190. continue
  191. for ad in ads:
  192. adgroup_id = int(ad.get("adgroup_id") or 0)
  193. current_status = str(ad.get("configured_status") or "")
  194. state = states.get(adgroup_id)
  195. paused_by_strategy = bool(
  196. (state or {}).get("paused_by_strategy")
  197. )
  198. operator_pause_mode = str(
  199. (state or {}).get("operator_pause_mode") or ""
  200. )
  201. operator_resume_at = (state or {}).get("operator_resume_at")
  202. if operator_resume_at and operator_resume_at.tzinfo is None:
  203. operator_resume_at = operator_resume_at.replace(tzinfo=SHANGHAI)
  204. operator_manually_opened = bool(
  205. operator_pause_mode == "UNTIL_MANUAL"
  206. and current_status == ACTIVE_STATUS
  207. )
  208. operator_pause_expired = bool(
  209. operator_pause_mode == "UNTIL_NEXT_DELIVERY"
  210. and operator_resume_at
  211. and operator_resume_at <= now
  212. )
  213. begin_date = str(ad.get("begin_date") or "")
  214. operator_schedule_manually_resumed = bool(
  215. operator_pause_mode == "UNTIL_NEXT_DELIVERY"
  216. and operator_resume_at
  217. and operator_resume_at > now
  218. and begin_date
  219. and begin_date < operator_resume_at.date().isoformat()
  220. )
  221. operator_pause_active = bool(
  222. operator_pause_mode
  223. and not operator_manually_opened
  224. and not operator_schedule_manually_resumed
  225. and not operator_pause_expired
  226. )
  227. if (
  228. operator_manually_opened
  229. or operator_schedule_manually_resumed
  230. ) and apply:
  231. clear_operator_pause(
  232. account_id,
  233. adgroup_id,
  234. action="OPERATOR_MANUAL_OVERRIDE",
  235. action_at=now,
  236. )
  237. results.append(
  238. {
  239. "account_id": account_id,
  240. "audience_name": account.get("audience_name"),
  241. "adgroup_id": adgroup_id,
  242. "adgroup_name": ad.get("adgroup_name"),
  243. "decision": "OPERATOR_MANUAL_OVERRIDE",
  244. "before_status": current_status,
  245. "target_status": current_status,
  246. "status": "success",
  247. "bid_change_needed": False,
  248. "status_change_needed": False,
  249. }
  250. )
  251. continue
  252. if operator_pause_expired:
  253. if apply and current_status == ACTIVE_STATUS:
  254. clear_operator_pause(
  255. account_id,
  256. adgroup_id,
  257. action="OPERATOR_DAY_PAUSE_EXPIRED",
  258. action_at=now,
  259. )
  260. # Legacy DAY_PAUSE rows used SUSPEND. Keep the old one-time
  261. # recovery plan only for those rows; new rows stay NORMAL and
  262. # resume automatically through begin_date/time_series.
  263. operator_pause_expired = current_status != ACTIVE_STATUS
  264. if operator_pause_active:
  265. continue
  266. if (
  267. current_status != ACTIVE_STATUS
  268. and not paused_by_strategy
  269. and not operator_pause_expired
  270. and (
  271. state is None
  272. or decision not in (DECISION_PAUSE, DECISION_CUTOFF)
  273. )
  274. ):
  275. continue
  276. bid_field = (
  277. str(state["bid_field"])
  278. if state
  279. else resolve_bid_field(ad, account.get("bid_scene"))
  280. )
  281. current_bid = current_bid_fen(ad, bid_field)
  282. if current_bid is None or current_bid <= 0:
  283. failures += 1
  284. results.append(
  285. {
  286. "account_id": account_id,
  287. "adgroup_id": adgroup_id,
  288. "status": "invalid_current_bid",
  289. "bid_field": bid_field,
  290. }
  291. )
  292. continue
  293. base_bid = int(state["base_bid_fen"]) if state else current_bid
  294. plan = target_for_ad(
  295. decision=decision,
  296. control_date=now.date(),
  297. current_status=current_status,
  298. current_bid=current_bid,
  299. base_bid=base_bid,
  300. boosted_date=(state or {}).get("boosted_date"),
  301. paused_by_strategy=paused_by_strategy,
  302. operator_pause_expired=operator_pause_expired,
  303. bid_up_ratio=config.bid_up_ratio,
  304. )
  305. if plan is None:
  306. continue
  307. bid_hold = bool((state or {}).get("bid_hold"))
  308. if bid_hold:
  309. plan["target_bid_fen"] = current_bid
  310. plan["bid_change_needed"] = False
  311. before_begin_date = str(ad.get("begin_date") or "")
  312. target_begin_date = None
  313. begin_date_change_needed = False
  314. if plan["defer_to_next_day"]:
  315. next_day = (now.date() + timedelta(days=1)).isoformat()
  316. target_begin_date = (
  317. before_begin_date
  318. if before_begin_date > next_day
  319. else next_day
  320. )
  321. begin_date_change_needed = before_begin_date != target_begin_date
  322. needs_api = (
  323. plan["bid_change_needed"]
  324. or plan["status_change_needed"]
  325. or begin_date_change_needed
  326. )
  327. execution_status = "planned" if needs_api else "noop"
  328. error = None
  329. if apply and state is None:
  330. upsert_ad_state(
  331. account_id=account_id,
  332. adgroup_id=adgroup_id,
  333. adgroup_name=str(ad.get("adgroup_name") or ""),
  334. bid_field=bid_field,
  335. base_bid_fen=base_bid,
  336. boosted_date=None,
  337. paused_by_strategy=plan["paused_by_strategy"],
  338. pause_reason=plan["pause_reason"],
  339. last_action="REGISTER_BASE",
  340. action_at=now,
  341. )
  342. elif (
  343. apply
  344. and decision in (DECISION_PAUSE, DECISION_CUTOFF)
  345. and plan["paused_by_strategy"]
  346. ):
  347. upsert_ad_state(
  348. account_id=account_id,
  349. adgroup_id=adgroup_id,
  350. adgroup_name=str(ad.get("adgroup_name") or ""),
  351. bid_field=bid_field,
  352. base_bid_fen=base_bid,
  353. boosted_date=(state or {}).get("boosted_date"),
  354. paused_by_strategy=True,
  355. pause_reason=plan["pause_reason"],
  356. last_action=f"{decision}_PENDING",
  357. action_at=now,
  358. )
  359. if apply and needs_api:
  360. try:
  361. if begin_date_change_needed:
  362. tencent.update_ad_begin_dates(
  363. account_id,
  364. [adgroup_id],
  365. str(target_begin_date),
  366. )
  367. if plan["bid_change_needed"] or plan["status_change_needed"]:
  368. tencent.update_ad(
  369. account_id,
  370. adgroup_id,
  371. bid_field=bid_field
  372. if plan["bid_change_needed"]
  373. else None,
  374. target_bid_fen=plan["target_bid_fen"]
  375. if plan["bid_change_needed"]
  376. else None,
  377. target_status=plan["target_status"]
  378. if plan["status_change_needed"]
  379. else None,
  380. )
  381. execution_status = "success"
  382. api_updates += 1
  383. time.sleep(0.15)
  384. except Exception as exc:
  385. execution_status = "failed"
  386. error = str(exc)
  387. failures += 1
  388. result = {
  389. "account_id": account_id,
  390. "audience_name": account.get("audience_name"),
  391. "adgroup_id": adgroup_id,
  392. "adgroup_name": ad.get("adgroup_name"),
  393. "decision": decision,
  394. "bid_field": bid_field,
  395. "base_bid_fen": base_bid,
  396. "before_bid_fen": current_bid,
  397. "target_bid_fen": plan["target_bid_fen"],
  398. "before_status": current_status,
  399. "target_status": plan["target_status"],
  400. "boost_limit_reached": plan["boost_limit_reached"],
  401. "bid_hold": bid_hold,
  402. "bid_change_needed": plan["bid_change_needed"],
  403. "status_change_needed": plan["status_change_needed"],
  404. "before_begin_date": before_begin_date,
  405. "target_begin_date": target_begin_date,
  406. "begin_date_change_needed": begin_date_change_needed,
  407. "status": execution_status,
  408. "error": error,
  409. }
  410. results.append(result)
  411. if apply and execution_status != "failed":
  412. upsert_ad_state(
  413. account_id=account_id,
  414. adgroup_id=adgroup_id,
  415. adgroup_name=str(ad.get("adgroup_name") or ""),
  416. bid_field=bid_field,
  417. base_bid_fen=base_bid,
  418. boosted_date=plan["boosted_date"],
  419. paused_by_strategy=plan["paused_by_strategy"],
  420. pause_reason=plan["pause_reason"],
  421. last_action=decision,
  422. action_at=now,
  423. )
  424. if operator_pause_expired:
  425. clear_operator_pause(
  426. account_id,
  427. adgroup_id,
  428. action="OPERATOR_DAY_PAUSE_EXPIRED",
  429. action_at=now,
  430. )
  431. if apply and (needs_api or state is None or execution_status == "failed"):
  432. insert_action_log(
  433. {
  434. "run_id": run_id,
  435. "control_date": now.date(),
  436. "observed_partition": observed_partition,
  437. "observed_cpm": observed_cpm,
  438. "decision": decision,
  439. "account_id": account_id,
  440. "adgroup_id": adgroup_id,
  441. "adgroup_name": ad.get("adgroup_name"),
  442. "bid_field": bid_field,
  443. "base_bid_fen": base_bid,
  444. "before_bid_fen": current_bid,
  445. "target_bid_fen": plan["target_bid_fen"],
  446. "before_status": current_status,
  447. "target_status": plan["target_status"],
  448. "apply_mode": True,
  449. "execution_status": execution_status,
  450. "error_message": error,
  451. }
  452. )
  453. notification = {"status": "dry_run" if not apply else "no_actions"}
  454. if apply or test_notification:
  455. from feishu_notifier import send_action_notification
  456. report_actions: dict[int, list[dict[str, Any]]] = {}
  457. expected_statuses = {"success", "failed"} if apply else {"planned"}
  458. for result in results:
  459. if result.get("status") not in expected_statuses:
  460. continue
  461. if not (
  462. result.get("bid_change_needed")
  463. or result.get("begin_date_change_needed")
  464. or (
  465. result.get("decision") in (DECISION_PAUSE, DECISION_CUTOFF)
  466. and result.get("status_change_needed")
  467. )
  468. ):
  469. continue
  470. report_actions.setdefault(int(result["account_id"]), []).append(
  471. result
  472. )
  473. for account_id, account_results in report_actions.items():
  474. try:
  475. metrics = tencent.get_today_ad_metrics(
  476. account_id,
  477. [int(row["adgroup_id"]) for row in account_results],
  478. now.date(),
  479. )
  480. for result in account_results:
  481. ad_metrics = metrics.get(int(result["adgroup_id"]), {})
  482. result["today_cost_fen"] = int(
  483. ad_metrics.get("cost_fen") or 0
  484. )
  485. result["today_impressions"] = int(
  486. ad_metrics.get("impressions") or 0
  487. )
  488. result["today_clicks"] = int(
  489. ad_metrics.get("clicks") or 0
  490. )
  491. except Exception as exc:
  492. logger.warning(
  493. "Failed to fetch today's metrics account=%s: %s",
  494. account_id,
  495. exc,
  496. )
  497. notification = send_action_notification(
  498. run_id=run_id,
  499. now=now,
  500. results=results,
  501. bid_up_ratio=config.bid_up_ratio,
  502. decision=decision,
  503. observed_partition=observed_partition,
  504. observed_cpm=observed_cpm,
  505. test_mode=test_notification,
  506. )
  507. if apply:
  508. try:
  509. from feishu_notifier import (
  510. send_operator_reconciliation_notification,
  511. )
  512. send_operator_reconciliation_notification(results)
  513. except Exception:
  514. logger.exception(
  515. "Failed to notify operator manual override reconciliation"
  516. )
  517. return {
  518. "failures": failures,
  519. "api_updates": api_updates,
  520. "results": results,
  521. "notification": notification,
  522. }
  523. def run_cycle(
  524. *,
  525. now: datetime,
  526. apply: bool,
  527. cpm_override: Decimal | None = None,
  528. force_refresh: bool = False,
  529. test_notification: bool = False,
  530. ) -> dict[str, Any]:
  531. from odps_source import HourlyCpm, build_odps_client, fetch_latest_cpm
  532. from realtime_config import RealtimeControlConfig
  533. from storage import (
  534. advisory_lock,
  535. load_daily_state,
  536. load_realtime_accounts,
  537. save_daily_state,
  538. )
  539. from tencent_client import TencentClient
  540. config = RealtimeControlConfig.from_env()
  541. run_id = str(uuid.uuid4())
  542. payload: dict[str, Any] = {
  543. "run_id": run_id,
  544. "now": now.isoformat(),
  545. "apply": apply,
  546. "decision": None,
  547. "observed_partition": None,
  548. "observed_cpm": None,
  549. "summary": {},
  550. }
  551. with advisory_lock(config.lock_name) as acquired:
  552. if not acquired:
  553. payload["decision"] = "LOCKED_BY_OTHER_WORKER"
  554. return payload
  555. daily_state = load_daily_state(now.date())
  556. accounts = load_realtime_accounts()
  557. if now.hour < config.start_hour:
  558. payload["decision"] = DECISION_OFF_HOURS
  559. return payload
  560. if now.hour >= config.stop_hour:
  561. payload["decision"] = DECISION_CUTOFF
  562. if apply and bool(daily_state.get("cutoff_done")) and not force_refresh:
  563. payload["summary"] = {"status": "cutoff_already_done"}
  564. return payload
  565. execution = execute_inventory_action(
  566. run_id=run_id,
  567. now=now,
  568. decision=DECISION_RESTORE,
  569. observed_partition=None,
  570. observed_cpm=None,
  571. apply=apply,
  572. accounts=accounts,
  573. config=config,
  574. tencent=TencentClient(),
  575. test_notification=test_notification,
  576. )
  577. payload["summary"] = execution
  578. if apply and not execution["failures"]:
  579. save_daily_state(
  580. now.date(),
  581. cutoff_done=True,
  582. last_decision=DECISION_CUTOFF,
  583. last_inventory_refresh_at=now,
  584. last_evaluated_at=now,
  585. )
  586. return payload
  587. if apply and not bool(daily_state.get("morning_recovery_done")):
  588. execution = execute_inventory_action(
  589. run_id=run_id,
  590. now=now,
  591. decision=DECISION_RESTORE,
  592. observed_partition=None,
  593. observed_cpm=None,
  594. apply=True,
  595. accounts=accounts,
  596. config=config,
  597. tencent=TencentClient(),
  598. test_notification=test_notification,
  599. )
  600. payload["decision"] = "MORNING_RECOVERY"
  601. payload["summary"] = execution
  602. if not execution["failures"]:
  603. save_daily_state(
  604. now.date(),
  605. morning_recovery_done=True,
  606. last_inventory_refresh_at=now,
  607. last_evaluated_at=now,
  608. )
  609. else:
  610. return payload
  611. if cpm_override is not None:
  612. signal = HourlyCpm(
  613. partition=f"{now.strftime('%Y%m%d%H')}_override",
  614. hour_of_day=now.hour,
  615. impressions=0,
  616. cpm=float(cpm_override),
  617. )
  618. else:
  619. signal = fetch_latest_cpm(build_odps_client(), now.date())
  620. earliest_signal_hour = max(0, config.start_hour - 1)
  621. if signal is None or signal.hour_of_day < earliest_signal_hour:
  622. payload["decision"] = DECISION_WAIT_DATA
  623. payload["summary"] = {
  624. "reason": (
  625. "No same-day CPM partition at or after "
  626. f"{earliest_signal_hour:02d}:00"
  627. )
  628. }
  629. return payload
  630. observed_cpm = Decimal(str(signal.cpm))
  631. lag_hours = partition_lag_hours(signal.partition, now)
  632. payload.update(
  633. {
  634. "observed_partition": signal.partition,
  635. "observed_cpm": float(observed_cpm),
  636. "impressions": signal.impressions,
  637. "partition_lag_hours": round(lag_hours, 4),
  638. }
  639. )
  640. if lag_hours < 0 or lag_hours > config.max_partition_lag_hours:
  641. payload["decision"] = DECISION_WAIT_DATA
  642. payload["summary"] = {
  643. "reason": (
  644. f"CPM partition lag {lag_hours:.2f}h exceeds allowed "
  645. f"{config.max_partition_lag_hours}h"
  646. )
  647. }
  648. return payload
  649. decision = decide(observed_cpm, config.low_cpm, config.high_cpm)
  650. payload.update(
  651. {
  652. "decision": decision,
  653. }
  654. )
  655. refresh = force_refresh or should_refresh_inventory(
  656. daily_state,
  657. observed_partition=signal.partition,
  658. decision=decision,
  659. now=now,
  660. refresh_minutes=config.inventory_refresh_minutes,
  661. )
  662. if apply and not refresh:
  663. save_daily_state(now.date(), last_evaluated_at=now)
  664. payload["summary"] = {"status": "same_signal_noop"}
  665. return payload
  666. execution = execute_inventory_action(
  667. run_id=run_id,
  668. now=now,
  669. decision=decision,
  670. observed_partition=signal.partition,
  671. observed_cpm=observed_cpm,
  672. apply=apply,
  673. accounts=accounts,
  674. config=config,
  675. tencent=TencentClient(),
  676. test_notification=test_notification,
  677. )
  678. payload["summary"] = execution
  679. if apply and not execution["failures"]:
  680. save_daily_state(
  681. now.date(),
  682. last_observed_partition=signal.partition,
  683. last_observed_cpm=observed_cpm,
  684. last_decision=decision,
  685. last_inventory_refresh_at=now,
  686. last_evaluated_at=now,
  687. )
  688. return payload
  689. def parse_args() -> argparse.Namespace:
  690. parser = argparse.ArgumentParser(description=__doc__)
  691. parser.add_argument(
  692. "--apply",
  693. action="store_true",
  694. help="Execute Tencent updates. Default is dry-run.",
  695. )
  696. parser.add_argument(
  697. "--at",
  698. help="Dry-run only: simulate Shanghai local time, ISO format.",
  699. )
  700. parser.add_argument(
  701. "--cpm-override",
  702. type=Decimal,
  703. help="Dry-run only: override latest CPM to validate decision branches.",
  704. )
  705. parser.add_argument("--force-refresh", action="store_true")
  706. parser.add_argument(
  707. "--test-notification",
  708. action="store_true",
  709. help=(
  710. "Dry-run only: use real inventory and metrics, then send a clearly "
  711. "marked simulated Feishu action sheet."
  712. ),
  713. )
  714. return parser.parse_args()
  715. def main() -> int:
  716. load_environment()
  717. args = parse_args()
  718. if args.apply and (args.at or args.cpm_override is not None):
  719. raise ValueError("--at and --cpm-override are forbidden with --apply")
  720. if args.apply and args.test_notification:
  721. raise ValueError("--test-notification is forbidden with --apply")
  722. now = (
  723. datetime.fromisoformat(args.at).replace(tzinfo=SHANGHAI)
  724. if args.at
  725. else datetime.now(SHANGHAI)
  726. )
  727. payload = run_cycle(
  728. now=now,
  729. apply=args.apply,
  730. cpm_override=args.cpm_override,
  731. force_refresh=args.force_refresh,
  732. test_notification=args.test_notification,
  733. )
  734. report = write_run_report(payload)
  735. compact = {
  736. "decision": payload["decision"],
  737. "observed_partition": payload.get("observed_partition"),
  738. "observed_cpm": payload.get("observed_cpm"),
  739. "apply": payload["apply"],
  740. "api_updates": payload.get("summary", {}).get("api_updates", 0),
  741. "failures": payload.get("summary", {}).get("failures", 0),
  742. "notification": payload.get("summary", {}).get("notification", {}).get(
  743. "status"
  744. ),
  745. "report": str(report),
  746. }
  747. print(json.dumps(compact, ensure_ascii=False))
  748. return 1 if compact["failures"] else 0
  749. if __name__ == "__main__":
  750. logging.basicConfig(
  751. level=logging.INFO,
  752. format="%(asctime)s %(levelname)s %(name)s %(message)s",
  753. )
  754. try:
  755. raise SystemExit(main())
  756. except Exception as exc:
  757. logger.exception("Real-time control failed: %s", exc)
  758. raise SystemExit(1)