config.py 10 KB

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