revenue_forecast_source.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """Read same-source 15-minute business revenue series from ODPS."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from datetime import date, datetime, timedelta
  5. from decimal import Decimal
  6. from typing import Any
  7. from zoneinfo import ZoneInfo
  8. from odps import ODPS
  9. from revenue_forecast import RevenueObservation, RevenueTrendWindow
  10. SHANGHAI = ZoneInfo("Asia/Shanghai")
  11. SOURCE_TABLE = "ads_ad_own_package_detail_day"
  12. INTERVAL_TABLE = "ads_ad_own_package_detail_15min"
  13. COST_TABLE = "opengid_base_data"
  14. MINIAPP_CHANNEL = "小程序投流-稳定"
  15. @dataclass(frozen=True)
  16. class RevenueSourceSnapshot:
  17. observation: RevenueObservation
  18. trend_windows: tuple[RevenueTrendWindow, ...]
  19. def fetch_daily_channel_costs(
  20. client: ODPS,
  21. data_date: date,
  22. ) -> tuple[tuple[str, Decimal], ...]:
  23. sql = f"""
  24. SELECT
  25. NVL(channel, '') AS channel,
  26. SUM(NVL(`成本`, 0)) AS cost_yuan
  27. FROM loghubods.{COST_TABLE}
  28. WHERE dt = '{data_date:%Y%m%d}'
  29. AND usersharedepth = '0'
  30. AND videoid IS NOT NULL
  31. AND NVL(hotsencetype, '') <> '1167'
  32. GROUP BY NVL(channel, '')
  33. ORDER BY cost_yuan DESC
  34. """.strip()
  35. instance = client.execute_sql(
  36. sql,
  37. hints={"odps.sql.submit.mode": "script"},
  38. )
  39. with instance.open_reader(tunnel=True) as reader:
  40. rows = reader.to_pandas()
  41. if rows.empty:
  42. return ()
  43. costs: list[tuple[str, Decimal]] = []
  44. for row in rows.itertuples(index=False):
  45. cost = Decimal(str(row.cost_yuan or 0))
  46. if cost < 0:
  47. raise ValueError(f"Negative channel cost: {row.channel}={cost}")
  48. costs.append((str(row.channel), cost))
  49. return tuple(costs)
  50. def _decimal(value: Any) -> Decimal | None:
  51. if value is None or value == "":
  52. return None
  53. return Decimal(str(value).rstrip("%"))
  54. def _latest_today_row(partition: Any) -> Any | None:
  55. rows = []
  56. with partition.open_reader() as reader:
  57. for row in reader:
  58. if str(row["data_type"]) == "today" and row["report_date"]:
  59. rows.append(row)
  60. return max(rows, key=lambda row: str(row["report_date"]), default=None)
  61. def _partition(table: Any, data_date: date) -> Any | None:
  62. spec = f"dt='{data_date:%Y%m%d}'"
  63. if not table.exist_partition(spec):
  64. return None
  65. return table.get_partition(spec)
  66. def _fetch_interval_today_series(
  67. client: ODPS,
  68. data_dates: tuple[date, ...],
  69. ) -> dict[date, list[tuple[datetime, Decimal]]]:
  70. partition_filter = " OR ".join(
  71. (
  72. f"(dt >= '{item:%Y%m%d}000000' "
  73. f"AND dt <= '{item:%Y%m%d}235959')"
  74. )
  75. for item in data_dates
  76. )
  77. sql = f"""
  78. SELECT data_date, report_date, today_revenue
  79. FROM (
  80. SELECT
  81. data_date,
  82. dt,
  83. report_date,
  84. today_revenue,
  85. ROW_NUMBER() OVER (PARTITION BY dt ORDER BY report_date DESC) AS row_num
  86. FROM (
  87. SELECT
  88. SUBSTR(dt, 1, 8) AS data_date,
  89. dt,
  90. report_date,
  91. MIN(NVL(package_cost_times_today, 0)) AS today_revenue
  92. FROM loghubods.{INTERVAL_TABLE}
  93. WHERE ({partition_filter})
  94. AND data_type = 'today'
  95. AND report_date IS NOT NULL
  96. GROUP BY SUBSTR(dt, 1, 8), dt, report_date
  97. ) deduplicated
  98. ) ranked
  99. WHERE row_num = 1
  100. ORDER BY data_date, report_date
  101. """.strip()
  102. instance = client.execute_sql(
  103. sql,
  104. hints={"odps.sql.submit.mode": "script"},
  105. )
  106. with instance.open_reader(tunnel=True) as reader:
  107. rows = reader.to_pandas()
  108. result = {item: [] for item in data_dates}
  109. for row in rows.itertuples(index=False):
  110. data_date = datetime.strptime(str(row.data_date), "%Y%m%d").date()
  111. revenue = _decimal(row.today_revenue)
  112. if revenue is None:
  113. continue
  114. report_time = datetime.strptime(
  115. str(row.report_date), "%Y%m%d%H%M%S"
  116. ).replace(tzinfo=SHANGHAI)
  117. result[data_date].append((report_time, revenue))
  118. return result
  119. def fetch_interval_revenue_series(
  120. client: ODPS,
  121. data_dates: tuple[date, ...],
  122. ) -> dict[date, list[tuple[datetime, Decimal]]]:
  123. """Return each date's deduplicated 15-minute `today` interval series."""
  124. if not data_dates:
  125. return {}
  126. return _fetch_interval_today_series(client, data_dates)
  127. def _build_trend_windows(
  128. current: list[tuple[datetime, Decimal]],
  129. *,
  130. window_minutes: int,
  131. window_count: int,
  132. ) -> tuple[RevenueTrendWindow, ...]:
  133. windows: list[RevenueTrendWindow] = []
  134. for report_time, today_revenue in current[-window_count:]:
  135. windows.append(
  136. RevenueTrendWindow(
  137. report_time=report_time + timedelta(minutes=window_minutes),
  138. today_revenue=today_revenue,
  139. )
  140. )
  141. return tuple(windows)
  142. def fetch_revenue_source_snapshot(
  143. client: ODPS,
  144. data_date: date,
  145. *,
  146. window_minutes: int = 15,
  147. window_count: int = 3,
  148. as_of: datetime | None = None,
  149. ) -> RevenueSourceSnapshot | None:
  150. series = _fetch_interval_today_series(client, (data_date,))
  151. current_values = series[data_date]
  152. if as_of is not None:
  153. cutoff = as_of.astimezone(SHANGHAI)
  154. current_values = [
  155. item
  156. for item in current_values
  157. if item[0] + timedelta(minutes=window_minutes) <= cutoff
  158. ]
  159. if not current_values:
  160. return None
  161. latest_window_start = current_values[-1][0]
  162. today_revenue = sum((value for _, value in current_values), Decimal("0"))
  163. metrics_table = client.get_table(SOURCE_TABLE)
  164. metrics_partition = _partition(metrics_table, data_date)
  165. metrics = (
  166. _latest_today_row(metrics_partition)
  167. if metrics_partition is not None
  168. else None
  169. )
  170. interval_table = client.get_table(INTERVAL_TABLE)
  171. latest_partition = _partition_at_time(interval_table, latest_window_start)
  172. modified_at = (
  173. latest_partition.last_data_modified_time
  174. if latest_partition is not None
  175. else None
  176. )
  177. if modified_at is not None and modified_at.tzinfo is None:
  178. modified_at = modified_at.replace(tzinfo=SHANGHAI)
  179. elif modified_at is not None:
  180. modified_at = modified_at.astimezone(SHANGHAI)
  181. def metric(name: str) -> Any | None:
  182. return metrics[name] if metrics is not None else None
  183. observation = RevenueObservation(
  184. partition=data_date.strftime("%Y%m%d"),
  185. report_time=latest_window_start + timedelta(minutes=window_minutes),
  186. today_revenue=today_revenue,
  187. overall_cpm=_decimal(metric("overall_cpm")),
  188. impressions=(
  189. int(metric("daily_exposure_cnt"))
  190. if metric("daily_exposure_cnt") is not None
  191. else None
  192. ),
  193. dau=(
  194. int(metric("dau_today"))
  195. if metric("dau_today") is not None
  196. else None
  197. ),
  198. fill_rate=_decimal(metric("fill_rate_today")),
  199. revenue_change_pct=_decimal(metric("package_cost_lastday_change_rate")),
  200. cpm_change_pct=_decimal(metric("cpm_lastday_change_rate")),
  201. exposure_change_pct=_decimal(metric("exposure_lastday_change_rate")),
  202. dau_change_pct=_decimal(metric("dau_lastday_change_rate")),
  203. fill_rate_change_pct=_decimal(metric("fill_rate_lastday_change_rate")),
  204. source_modified_at=modified_at,
  205. )
  206. return RevenueSourceSnapshot(
  207. observation=observation,
  208. trend_windows=_build_trend_windows(
  209. current_values,
  210. window_minutes=window_minutes,
  211. window_count=window_count,
  212. ),
  213. )
  214. def _partition_at_time(table: Any, report_time: datetime) -> Any | None:
  215. spec = f"dt='{report_time:%Y%m%d%H%M%S}'"
  216. if not table.exist_partition(spec):
  217. return None
  218. return table.get_partition(spec)