config.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Configuration for daily ROI computation and approved execution."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import math
  6. import os
  7. from dataclasses import asdict, dataclass
  8. from decimal import Decimal
  9. from typing import Mapping
  10. def env_flag(name: str, default: bool = False) -> bool:
  11. raw = os.getenv(name)
  12. if raw is None:
  13. return default
  14. return raw.strip().lower() in {"1", "true", "yes", "on"}
  15. AGENCY_WEBHOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"
  16. @dataclass(frozen=True)
  17. class AgencyWebhookConfig:
  18. """Secret-backed routing for per-agency ROI report notifications."""
  19. enabled: bool = False
  20. webhooks: Mapping[str, str] | None = None
  21. @classmethod
  22. def from_env(cls) -> "AgencyWebhookConfig":
  23. enabled = env_flag("ROI_AGENCY_WEBHOOK_ENABLED")
  24. if not enabled:
  25. return cls(enabled=False, webhooks={})
  26. raw = os.getenv("ROI_AGENCY_WEBHOOKS_JSON", "").strip()
  27. if not raw:
  28. raise ValueError(
  29. "ROI_AGENCY_WEBHOOK_ENABLED=1 requires ROI_AGENCY_WEBHOOKS_JSON"
  30. )
  31. try:
  32. values = json.loads(raw)
  33. except json.JSONDecodeError as exc:
  34. raise ValueError("ROI_AGENCY_WEBHOOKS_JSON must be valid JSON") from exc
  35. if not isinstance(values, dict):
  36. raise ValueError("ROI_AGENCY_WEBHOOKS_JSON must be a JSON object")
  37. webhooks: dict[str, str] = {}
  38. for raw_name, raw_url in values.items():
  39. name = str(raw_name).strip()
  40. url = str(raw_url).strip()
  41. if not name:
  42. raise ValueError("ROI agency webhook name cannot be empty")
  43. if not url.startswith(AGENCY_WEBHOOK_PREFIX):
  44. raise ValueError(f"Invalid Feishu webhook for agency: {name}")
  45. if name in webhooks:
  46. raise ValueError(f"Duplicate ROI agency webhook: {name}")
  47. webhooks[name] = url
  48. return cls(enabled=True, webhooks=webhooks)
  49. def snapshot(self) -> dict[str, object]:
  50. routes = self.webhooks or {}
  51. return {
  52. "enabled": self.enabled,
  53. "agencies": sorted(routes),
  54. "route_fingerprints": {
  55. name: hashlib.sha256(url.encode("utf-8")).hexdigest()[:12]
  56. for name, url in sorted(routes.items())
  57. },
  58. }
  59. @dataclass(frozen=True)
  60. class RoiConfig:
  61. daily_enabled: bool = False
  62. apply_enabled: bool = False
  63. sheet_approval_enabled: bool = False
  64. sheet_approval_poll_seconds: int = 60
  65. report_hour: int = 9
  66. report_minute: int = 0
  67. internal_test_enabled: bool = False
  68. internal_test_hour: int = 15
  69. internal_test_minute: int = 30
  70. internal_test_dedup_enabled: bool = True
  71. approval_ttl_minutes: int = 120
  72. scale_ratio: Decimal = Decimal("1.10")
  73. scale_cooldown_days: int = 3
  74. max_base_ratio: Decimal = Decimal("2.00")
  75. self_stop_min_age: int = 4
  76. self_up_min_age: int = 3
  77. self_min_daily_uv: float = 200
  78. partner_min_daily_uv: float = 200
  79. observe_min_latest_uv: float = 200
  80. one_day_min_uv: float = 200
  81. one_day_p30_min_uv: float = 500
  82. one_day_hard_stop_roi: float = 0.20
  83. one_day_stop_quantile: float = 0.30
  84. stop_quantile: float = 0.20
  85. up_quantile: float = 0.80
  86. stop_weight_cap_quantile: float = 0.95
  87. @classmethod
  88. def from_env(cls) -> "RoiConfig":
  89. config = cls(
  90. daily_enabled=env_flag("DAILY_ROI_ENABLED"),
  91. apply_enabled=env_flag("ROI_APPLY_ENABLED"),
  92. sheet_approval_enabled=env_flag("ROI_SHEET_APPROVAL_ENABLED"),
  93. sheet_approval_poll_seconds=int(
  94. os.getenv("ROI_SHEET_APPROVAL_POLL_SECONDS", "60")
  95. ),
  96. report_hour=int(os.getenv("DAILY_ROI_HOUR", "9")),
  97. report_minute=int(os.getenv("DAILY_ROI_MINUTE", "0")),
  98. internal_test_enabled=env_flag("ROI_INTERNAL_TEST_ENABLED"),
  99. internal_test_hour=int(os.getenv("ROI_INTERNAL_TEST_HOUR", "15")),
  100. internal_test_minute=int(os.getenv("ROI_INTERNAL_TEST_MINUTE", "30")),
  101. internal_test_dedup_enabled=env_flag(
  102. "ROI_INTERNAL_TEST_DEDUP_ENABLED",
  103. True,
  104. ),
  105. approval_ttl_minutes=int(
  106. os.getenv("ROI_APPROVAL_TTL_MINUTES", "120")
  107. ),
  108. scale_ratio=Decimal(os.getenv("ROI_SCALE_RATIO", "1.10")),
  109. scale_cooldown_days=int(os.getenv("ROI_SCALE_COOLDOWN_DAYS", "3")),
  110. max_base_ratio=Decimal(os.getenv("ROI_MAX_BASE_RATIO", "2.00")),
  111. self_stop_min_age=int(os.getenv("ROI_SELF_STOP_MIN_AGE", "4")),
  112. self_up_min_age=int(os.getenv("ROI_SELF_UP_MIN_AGE", "3")),
  113. self_min_daily_uv=float(
  114. os.getenv(
  115. "ROI_SELF_MIN_DAILY_UV",
  116. os.getenv("ROI_SELF_MIN_AVG_UV", "200"),
  117. )
  118. ),
  119. partner_min_daily_uv=float(
  120. os.getenv(
  121. "ROI_PARTNER_MIN_DAILY_UV",
  122. os.getenv("ROI_PARTNER_MIN_AVG_UV", "200"),
  123. )
  124. ),
  125. observe_min_latest_uv=float(
  126. os.getenv("ROI_OBSERVE_MIN_LATEST_UV", "200")
  127. ),
  128. one_day_min_uv=float(os.getenv("ROI_ONE_DAY_MIN_UV", "200")),
  129. one_day_p30_min_uv=float(
  130. os.getenv("ROI_ONE_DAY_P30_MIN_UV", "500")
  131. ),
  132. one_day_hard_stop_roi=float(
  133. os.getenv("ROI_ONE_DAY_HARD_STOP_ROI", "0.20")
  134. ),
  135. one_day_stop_quantile=float(
  136. os.getenv("ROI_ONE_DAY_STOP_QUANTILE", "0.30")
  137. ),
  138. stop_quantile=float(os.getenv("ROI_STOP_QUANTILE", "0.20")),
  139. up_quantile=float(os.getenv("ROI_UP_QUANTILE", "0.80")),
  140. stop_weight_cap_quantile=float(
  141. os.getenv("ROI_STOP_WEIGHT_CAP_QUANTILE", "0.95")
  142. ),
  143. )
  144. config.validate()
  145. return config
  146. def validate(self) -> None:
  147. if self.apply_enabled and not self.daily_enabled:
  148. raise ValueError(
  149. "ROI_APPLY_ENABLED=1 requires DAILY_ROI_ENABLED=1"
  150. )
  151. if self.sheet_approval_enabled and not self.apply_enabled:
  152. raise ValueError(
  153. "ROI_SHEET_APPROVAL_ENABLED=1 requires ROI_APPLY_ENABLED=1"
  154. )
  155. if self.sheet_approval_poll_seconds < 30:
  156. raise ValueError("ROI_SHEET_APPROVAL_POLL_SECONDS must be at least 30")
  157. if not 0 <= self.report_hour <= 23 or not 0 <= self.report_minute <= 59:
  158. raise ValueError("DAILY_ROI_HOUR/MINUTE is invalid")
  159. if not 0 <= self.internal_test_hour <= 23 or not 0 <= self.internal_test_minute <= 59:
  160. raise ValueError("ROI_INTERNAL_TEST_HOUR/MINUTE is invalid")
  161. if self.approval_ttl_minutes < 1:
  162. raise ValueError("ROI_APPROVAL_TTL_MINUTES must be positive")
  163. if self.scale_ratio <= 1:
  164. raise ValueError("ROI_SCALE_RATIO must be greater than 1")
  165. if self.max_base_ratio < self.scale_ratio:
  166. raise ValueError("ROI_MAX_BASE_RATIO must cover ROI_SCALE_RATIO")
  167. if self.scale_cooldown_days < 1:
  168. raise ValueError("ROI_SCALE_COOLDOWN_DAYS must be positive")
  169. if min(
  170. self.self_min_daily_uv,
  171. self.partner_min_daily_uv,
  172. self.observe_min_latest_uv,
  173. self.one_day_min_uv,
  174. self.one_day_p30_min_uv,
  175. ) <= 0:
  176. raise ValueError("ROI UV thresholds must be positive")
  177. if self.one_day_p30_min_uv <= self.one_day_min_uv:
  178. raise ValueError(
  179. "ROI_ONE_DAY_P30_MIN_UV must be greater than ROI_ONE_DAY_MIN_UV"
  180. )
  181. if not math.isfinite(self.one_day_hard_stop_roi):
  182. raise ValueError("ROI_ONE_DAY_HARD_STOP_ROI must be finite")
  183. if not 0 < self.one_day_stop_quantile < 1:
  184. raise ValueError(
  185. "ROI_ONE_DAY_STOP_QUANTILE must satisfy 0 < value < 1"
  186. )
  187. if not 0 < self.stop_quantile < 1:
  188. raise ValueError("ROI_STOP_QUANTILE must satisfy 0 < value < 1")
  189. if not self.stop_quantile < self.up_quantile < 1:
  190. raise ValueError(
  191. "ROI_UP_QUANTILE must satisfy ROI_STOP_QUANTILE < value < 1"
  192. )
  193. if not 0 < self.stop_weight_cap_quantile <= 1:
  194. raise ValueError(
  195. "ROI_STOP_WEIGHT_CAP_QUANTILE must satisfy 0 < value <= 1"
  196. )
  197. def snapshot(self) -> dict[str, object]:
  198. values = asdict(self)
  199. values["scale_ratio"] = str(self.scale_ratio)
  200. values["max_base_ratio"] = str(self.max_base_ratio)
  201. return values