config.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  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 = 11
  66. report_minute: int = 0
  67. daily_poll_interval_minutes: int = 30
  68. daily_poll_cutoff_hour: int = 18
  69. daily_poll_cutoff_minute: int = 0
  70. internal_test_enabled: bool = False
  71. internal_test_hour: int = 15
  72. internal_test_minute: int = 30
  73. internal_test_dedup_enabled: bool = True
  74. approval_ttl_minutes: int = 120
  75. scale_ratio: Decimal = Decimal("1.10")
  76. scale_cooldown_days: int = 3
  77. max_base_ratio: Decimal = Decimal("2.00")
  78. self_stop_min_age: int = 4
  79. self_up_min_age: int = 3
  80. self_min_daily_uv: float = 200
  81. partner_min_daily_uv: float = 200
  82. observe_min_latest_uv: float = 200
  83. one_day_min_uv: float = 200
  84. one_day_hard_stop_roi: float = 0.20
  85. one_day_stop_quantile: float = 0.10
  86. self_stop_cost_soft_lower_ratio: float = 0.045
  87. self_stop_cost_target_ratio: float = 0.05
  88. self_stop_cost_soft_upper_ratio: float = 0.055
  89. self_stop_cost_hard_cap_ratio: float = 0.06
  90. self_stop_three_day_share: float = 0.60
  91. self_stop_one_day_share: float = 0.40
  92. stop_quantile: float = 0.20
  93. up_quantile: float = 0.80
  94. stop_weight_cap_quantile: float = 0.95
  95. @classmethod
  96. def from_env(cls) -> "RoiConfig":
  97. config = cls(
  98. daily_enabled=env_flag("DAILY_ROI_ENABLED"),
  99. apply_enabled=env_flag("ROI_APPLY_ENABLED"),
  100. sheet_approval_enabled=env_flag("ROI_SHEET_APPROVAL_ENABLED"),
  101. sheet_approval_poll_seconds=int(
  102. os.getenv("ROI_SHEET_APPROVAL_POLL_SECONDS", "60")
  103. ),
  104. report_hour=int(os.getenv("DAILY_ROI_HOUR", "11")),
  105. report_minute=int(os.getenv("DAILY_ROI_MINUTE", "0")),
  106. daily_poll_interval_minutes=int(
  107. os.getenv("DAILY_ROI_POLL_INTERVAL_MINUTES", "30")
  108. ),
  109. daily_poll_cutoff_hour=int(
  110. os.getenv("DAILY_ROI_POLL_CUTOFF_HOUR", "18")
  111. ),
  112. daily_poll_cutoff_minute=int(
  113. os.getenv("DAILY_ROI_POLL_CUTOFF_MINUTE", "0")
  114. ),
  115. internal_test_enabled=env_flag("ROI_INTERNAL_TEST_ENABLED"),
  116. internal_test_hour=int(os.getenv("ROI_INTERNAL_TEST_HOUR", "15")),
  117. internal_test_minute=int(os.getenv("ROI_INTERNAL_TEST_MINUTE", "30")),
  118. internal_test_dedup_enabled=env_flag(
  119. "ROI_INTERNAL_TEST_DEDUP_ENABLED",
  120. True,
  121. ),
  122. approval_ttl_minutes=int(
  123. os.getenv("ROI_APPROVAL_TTL_MINUTES", "120")
  124. ),
  125. scale_ratio=Decimal(os.getenv("ROI_SCALE_RATIO", "1.10")),
  126. scale_cooldown_days=int(os.getenv("ROI_SCALE_COOLDOWN_DAYS", "3")),
  127. max_base_ratio=Decimal(os.getenv("ROI_MAX_BASE_RATIO", "2.00")),
  128. self_stop_min_age=int(os.getenv("ROI_SELF_STOP_MIN_AGE", "4")),
  129. self_up_min_age=int(os.getenv("ROI_SELF_UP_MIN_AGE", "3")),
  130. self_min_daily_uv=float(
  131. os.getenv(
  132. "ROI_SELF_MIN_DAILY_UV",
  133. os.getenv("ROI_SELF_MIN_AVG_UV", "200"),
  134. )
  135. ),
  136. partner_min_daily_uv=float(
  137. os.getenv(
  138. "ROI_PARTNER_MIN_DAILY_UV",
  139. os.getenv("ROI_PARTNER_MIN_AVG_UV", "200"),
  140. )
  141. ),
  142. observe_min_latest_uv=float(
  143. os.getenv("ROI_OBSERVE_MIN_LATEST_UV", "200")
  144. ),
  145. one_day_min_uv=float(os.getenv("ROI_ONE_DAY_MIN_UV", "200")),
  146. one_day_hard_stop_roi=float(
  147. os.getenv("ROI_ONE_DAY_HARD_STOP_ROI", "0.20")
  148. ),
  149. one_day_stop_quantile=float(
  150. os.getenv("ROI_ONE_DAY_STOP_QUANTILE", "0.10")
  151. ),
  152. self_stop_cost_soft_lower_ratio=float(
  153. os.getenv("ROI_SELF_STOP_COST_SOFT_LOWER_RATIO", "0.045")
  154. ),
  155. self_stop_cost_target_ratio=float(
  156. os.getenv("ROI_SELF_STOP_COST_TARGET_RATIO", "0.05")
  157. ),
  158. self_stop_cost_soft_upper_ratio=float(
  159. os.getenv("ROI_SELF_STOP_COST_SOFT_UPPER_RATIO", "0.055")
  160. ),
  161. self_stop_cost_hard_cap_ratio=float(
  162. os.getenv("ROI_SELF_STOP_COST_HARD_CAP_RATIO", "0.06")
  163. ),
  164. self_stop_three_day_share=float(
  165. os.getenv("ROI_SELF_STOP_THREE_DAY_SHARE", "0.60")
  166. ),
  167. self_stop_one_day_share=float(
  168. os.getenv("ROI_SELF_STOP_ONE_DAY_SHARE", "0.40")
  169. ),
  170. stop_quantile=float(
  171. os.getenv(
  172. "ROI_PARTNER_STOP_QUANTILE",
  173. os.getenv("ROI_STOP_QUANTILE", "0.20"),
  174. )
  175. ),
  176. up_quantile=float(os.getenv("ROI_UP_QUANTILE", "0.80")),
  177. stop_weight_cap_quantile=float(
  178. os.getenv("ROI_STOP_WEIGHT_CAP_QUANTILE", "0.95")
  179. ),
  180. )
  181. config.validate()
  182. return config
  183. def validate(self) -> None:
  184. if self.apply_enabled and not self.daily_enabled:
  185. raise ValueError(
  186. "ROI_APPLY_ENABLED=1 requires DAILY_ROI_ENABLED=1"
  187. )
  188. if self.sheet_approval_enabled and not self.apply_enabled:
  189. raise ValueError(
  190. "ROI_SHEET_APPROVAL_ENABLED=1 requires ROI_APPLY_ENABLED=1"
  191. )
  192. if self.sheet_approval_poll_seconds < 30:
  193. raise ValueError("ROI_SHEET_APPROVAL_POLL_SECONDS must be at least 30")
  194. if not 0 <= self.report_hour <= 23 or not 0 <= self.report_minute <= 59:
  195. raise ValueError("DAILY_ROI_HOUR/MINUTE is invalid")
  196. if (
  197. self.daily_poll_interval_minutes < 1
  198. or self.daily_poll_interval_minutes > 60
  199. or 60 % self.daily_poll_interval_minutes
  200. ):
  201. raise ValueError(
  202. "DAILY_ROI_POLL_INTERVAL_MINUTES must be a positive divisor of 60"
  203. )
  204. if (
  205. not 0 <= self.daily_poll_cutoff_hour <= 23
  206. or not 0 <= self.daily_poll_cutoff_minute <= 59
  207. ):
  208. raise ValueError("DAILY_ROI_POLL_CUTOFF_HOUR/MINUTE is invalid")
  209. if (self.daily_poll_cutoff_hour, self.daily_poll_cutoff_minute) <= (
  210. self.report_hour,
  211. self.report_minute,
  212. ):
  213. raise ValueError("DAILY_ROI poll cutoff must be later than report time")
  214. if not 0 <= self.internal_test_hour <= 23 or not 0 <= self.internal_test_minute <= 59:
  215. raise ValueError("ROI_INTERNAL_TEST_HOUR/MINUTE is invalid")
  216. if self.approval_ttl_minutes < 1:
  217. raise ValueError("ROI_APPROVAL_TTL_MINUTES must be positive")
  218. if self.scale_ratio <= 1:
  219. raise ValueError("ROI_SCALE_RATIO must be greater than 1")
  220. if self.max_base_ratio < self.scale_ratio:
  221. raise ValueError("ROI_MAX_BASE_RATIO must cover ROI_SCALE_RATIO")
  222. if self.scale_cooldown_days < 1:
  223. raise ValueError("ROI_SCALE_COOLDOWN_DAYS must be positive")
  224. if min(
  225. self.self_min_daily_uv,
  226. self.partner_min_daily_uv,
  227. self.observe_min_latest_uv,
  228. self.one_day_min_uv,
  229. ) <= 0:
  230. raise ValueError("ROI UV thresholds must be positive")
  231. if not math.isfinite(self.one_day_hard_stop_roi):
  232. raise ValueError("ROI_ONE_DAY_HARD_STOP_ROI must be finite")
  233. if not 0 < self.one_day_stop_quantile < 1:
  234. raise ValueError("ROI_ONE_DAY_STOP_QUANTILE must satisfy 0 < value < 1")
  235. if not (
  236. 0 <= self.self_stop_cost_soft_lower_ratio
  237. <= self.self_stop_cost_target_ratio
  238. <= self.self_stop_cost_soft_upper_ratio
  239. <= self.self_stop_cost_hard_cap_ratio
  240. <= 1
  241. ):
  242. raise ValueError(
  243. "ROI self stop cost ratios must satisfy 0 <= soft lower <= target "
  244. "<= soft upper <= hard cap <= 1"
  245. )
  246. shares = (
  247. self.self_stop_three_day_share,
  248. self.self_stop_one_day_share,
  249. )
  250. if min(shares) < 0 or not math.isclose(sum(shares), 1.0, abs_tol=1e-9):
  251. raise ValueError("ROI self stop allocation shares must be non-negative and sum to 1")
  252. if not 0 < self.stop_quantile < 1:
  253. raise ValueError(
  254. "ROI_PARTNER_STOP_QUANTILE must satisfy 0 < value < 1"
  255. )
  256. if not self.stop_quantile < self.up_quantile < 1:
  257. raise ValueError(
  258. "ROI_UP_QUANTILE must satisfy ROI_PARTNER_STOP_QUANTILE < value < 1"
  259. )
  260. if not 0 < self.stop_weight_cap_quantile <= 1:
  261. raise ValueError(
  262. "ROI_STOP_WEIGHT_CAP_QUANTILE must satisfy 0 < value <= 1"
  263. )
  264. def snapshot(self) -> dict[str, object]:
  265. values = asdict(self)
  266. values["scale_ratio"] = str(self.scale_ratio)
  267. values["max_base_ratio"] = str(self.max_base_ratio)
  268. return values