DM-Agent / src /monitor.py
wen Qiu
fix: 全局统一 ROAS 只用 Conversion 数据计算
7be5d72
Raw History Blame Contribute Delete
40.3 kB
"""
日常数据监控引擎:检测指标异常、数据更新状态。
不做业务归因,只客观报告异常波动。
"""
import logging
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import pandas as pd
from src.loader import load_config, load_daily_data
from src.campaign_parser import enrich_dataframe
log = logging.getLogger("ad-agent")
FILTER_DIMS = ("country", "platform", "ad_objective", "product_line", "product_model", "project_type")
def _load_enriched(config_path: Path) -> pd.DataFrame:
"""加载日度数据并用 campaign_parser 解析出额外维度列。"""
df = load_daily_data(config_path)
df = enrich_dataframe(df)
return df
MONITOR_LABEL_ZH = {
"Cost": "花费",
"Ads.Sales": "销售额",
"ROAS": "ROAS",
"CPC": "CPC",
"CTR": "CTR",
"CVR": "转化率",
"GA4.Sessions": "会话",
"ATC": "加购",
"Impressions": "展示量",
}
MONITOR_METRICS = {
"Cost": {"unit": "$", "threshold": 0.30},
"Ads.Sales": {"unit": "$", "threshold": 0.30},
"ROAS": {"unit": "", "threshold": 0.25},
"CPC": {"unit": "$", "threshold": 0.30},
"CTR": {"unit": "%", "threshold": 0.25},
"CVR": {"unit": "%", "threshold": 0.25},
"GA4.Sessions": {"unit": "", "threshold": 0.30},
"ATC": {"unit": "", "threshold": 0.35},
"Impressions": {"unit": "", "threshold": 0.30},
}
def _fmt_value(val: float, unit: str) -> str:
if val != val or val is None:
return "—"
if unit == "%":
return f"{val * 100:.2f}%" if abs(val) < 1 else f"{val:.2f}%"
if unit == "$":
if abs(val) >= 1e6:
return f"${val / 1e6:.2f}M"
if abs(val) >= 1e3:
return f"${val / 1e3:.1f}K"
return f"${val:.2f}"
if abs(val) >= 1e6:
return f"{val / 1e6:.2f}M"
if abs(val) >= 1e3:
return f"{val / 1e3:.1f}K"
return f"{val:.2f}"
def _conv_filter(df: pd.DataFrame) -> pd.DataFrame:
"""返回仅 Conversion funnel 的行,用于 ROAS 计算。"""
if "campaign_type" in df.columns:
return df[df["campaign_type"] == "Conversion"]
return df
def _conv_roas(df: pd.DataFrame) -> float | None:
"""用 Conversion 行计算 ROAS。"""
conv = _conv_filter(df)
cost = conv["Cost"].sum() if "Cost" in conv.columns else 0
sales = conv["Ads.Sales"].sum() if "Ads.Sales" in conv.columns else 0
return sales / cost if cost > 0 else None
def _agg_metrics(today_df: pd.DataFrame, ref_df: pd.DataFrame) -> dict[str, Any]:
"""对一组数据计算核心指标当日值和 7 日均值变化。ROAS 只用 Conversion 数据。"""
cost = today_df["Cost"].sum() if "Cost" in today_df.columns else 0
sales = today_df["Ads.Sales"].sum() if "Ads.Sales" in today_df.columns else 0
imp = today_df["Impressions"].sum() if "Impressions" in today_df.columns else 0
roas = _conv_roas(today_df)
result = {
"Cost": round(cost, 2),
"Ads.Sales": round(sales, 2),
"ROAS": round(roas, 2) if roas else None,
"Impressions": int(imp),
}
if not ref_df.empty:
c7 = ref_df.groupby("date")["Cost"].sum().mean() if "Cost" in ref_df.columns else 0
s7 = ref_df.groupby("date")["Ads.Sales"].sum().mean() if "Ads.Sales" in ref_df.columns else 0
conv_ref = _conv_filter(ref_df)
rc7 = conv_ref.groupby("date")["Cost"].sum().mean() if not conv_ref.empty and "Cost" in conv_ref.columns else 0
rs7 = conv_ref.groupby("date")["Ads.Sales"].sum().mean() if not conv_ref.empty and "Ads.Sales" in conv_ref.columns else 0
r7 = rs7 / rc7 if rc7 > 0 else None
if c7 > 0:
result["cost_chg"] = round((cost - c7) / c7 * 100, 1)
if s7 > 0:
result["sales_chg"] = round((sales - s7) / s7 * 100, 1)
if r7 and r7 > 0 and roas:
result["roas_chg"] = round((roas - r7) / r7 * 100, 1)
return result
_TOP_CAMPAIGNS_N = 10
def _agg_campaign_metrics(camp_today: pd.DataFrame, camp_ref: pd.DataFrame) -> dict[str, Any]:
"""Campaign 级聚合:Cost / Sales / ROAS + vs 7日均值变化。ROAS 只用 Conversion。"""
cost = camp_today["Cost"].sum() if "Cost" in camp_today.columns else 0
sales = camp_today["Ads.Sales"].sum() if "Ads.Sales" in camp_today.columns else 0
roas = _conv_roas(camp_today)
result: dict[str, Any] = {
"Cost": round(cost, 2),
"Ads.Sales": round(sales, 2),
"ROAS": round(roas, 2) if roas else None,
}
if not camp_ref.empty:
conv_ref = _conv_filter(camp_ref)
rc7 = conv_ref.groupby("date")["Cost"].sum().mean() if not conv_ref.empty and "Cost" in conv_ref.columns else 0
rs7 = conv_ref.groupby("date")["Ads.Sales"].sum().mean() if not conv_ref.empty and "Ads.Sales" in conv_ref.columns else 0
r7 = rs7 / rc7 if rc7 > 0 else None
c7 = camp_ref.groupby("date")["Cost"].sum().mean() if "Cost" in camp_ref.columns else 0
result["avg7_cost"] = round(c7, 2) if c7 else None
result["avg7_roas"] = round(r7, 2) if r7 else None
if c7 > 0:
result["cost_chg"] = round((cost - c7) / c7 * 100, 1)
if r7 and r7 > 0 and roas:
result["roas_chg"] = round((roas - r7) / r7 * 100, 1)
return result
def _build_hierarchy(
day_df: pd.DataFrame, ref_df: pd.DataFrame,
full_df: pd.DataFrame | None = None, target_date: date | None = None,
) -> dict[str, Any]:
"""
构建 国家 -> 平台 -> Campaign Type -> Campaign Name 的四层聚合。
对当天无数据的平台,自动回退取前一天数据并标记 fallback_date。
Campaign Name 层只展示花费 Top N,避免数据量爆炸。
"""
has_country = "country" in day_df.columns
has_platform = "platform" in day_df.columns
has_type = "campaign_type" in day_df.columns
has_campaign = "campaign_name" in day_df.columns
fallback_platforms: dict[str, str] = {}
if full_df is not None and has_platform and target_date is not None:
present = set(day_df["platform"].dropna().unique()) if not day_df.empty else set()
all_plats = set(full_df["platform"].dropna().unique()) if "platform" in full_df.columns else set()
for plat in all_plats - present:
p_df = full_df[full_df["platform"] == plat]
if p_df.empty:
continue
prev_date = p_df["date"].max()
if (pd.Timestamp(target_date) - prev_date).days <= 3:
fb_rows = p_df[p_df["date"] == prev_date]
day_df = pd.concat([day_df, fb_rows], ignore_index=True)
fallback_platforms[plat] = str(prev_date.date())
total = _agg_metrics(day_df, ref_df)
by_country: dict[str, Any] = {}
countries = sorted(day_df["country"].dropna().unique()) if has_country else []
for country in countries:
c_today = day_df[day_df["country"] == country]
c_ref = ref_df[ref_df["country"] == country] if has_country and not ref_df.empty else pd.DataFrame()
c_data: dict[str, Any] = {"total": _agg_metrics(c_today, c_ref), "by_platform": {}}
platforms = sorted(c_today["platform"].dropna().unique()) if has_platform else []
for plat in platforms:
p_today = c_today[c_today["platform"] == plat]
p_ref = c_ref[c_ref["platform"] == plat] if has_platform and not c_ref.empty else pd.DataFrame()
p_data: dict[str, Any] = {"total": _agg_metrics(p_today, p_ref), "by_type": {}}
if plat in fallback_platforms:
p_data["fallback_date"] = fallback_platforms[plat]
types = sorted(p_today["campaign_type"].dropna().unique()) if has_type else []
for ctype in types:
t_today = p_today[p_today["campaign_type"] == ctype]
t_ref = p_ref[p_ref["campaign_type"] == ctype] if has_type and not p_ref.empty else pd.DataFrame()
type_node = _agg_metrics(t_today, t_ref)
if has_campaign and not t_today.empty:
camp_costs = t_today.groupby("campaign_name")["Cost"].sum()
top_names = camp_costs.nlargest(_TOP_CAMPAIGNS_N).index.tolist()
top_campaigns = []
for cname in top_names:
cm_today = t_today[t_today["campaign_name"] == cname]
cm_ref = (
t_ref[t_ref["campaign_name"] == cname]
if has_campaign and not t_ref.empty and "campaign_name" in t_ref.columns
else pd.DataFrame()
)
cm = _agg_campaign_metrics(cm_today, cm_ref)
cm["campaign_name"] = cname
top_campaigns.append(cm)
type_node["top_campaigns"] = top_campaigns
p_data["by_type"][ctype] = type_node
c_data["by_platform"][plat] = p_data
by_country[country] = c_data
return {"total": total, "by_country": by_country, "fallback_platforms": fallback_platforms}
def _build_campaign_alerts(
day_df: pd.DataFrame, ref_df: pd.DataFrame,
) -> list[dict[str, Any]]:
"""
检测 Campaign 级异动:
1. 花费暴涨:今日花费 > 7日均值 * 2.5(且今日花费 > $200 避免噪声)
2. ROAS 骤降:ROAS 下降 > 50%(且今日花费 > $100)
3. 花费归零:7日内有花费(均值 > $100)但今日为 0
"""
if "campaign_name" not in day_df.columns:
return []
campaign_alerts: list[dict[str, Any]] = []
conv_day = _conv_filter(day_df)
today_by_camp = day_df.groupby("campaign_name").agg(
cost=("Cost", "sum"),
).reset_index() if "Cost" in day_df.columns else pd.DataFrame()
if today_by_camp.empty:
return campaign_alerts
conv_today_by_camp = conv_day.groupby("campaign_name").agg(
conv_cost=("Cost", "sum"),
conv_sales=("Ads.Sales", "sum"),
).reset_index() if not conv_day.empty and "Cost" in conv_day.columns else pd.DataFrame(columns=["campaign_name", "conv_cost", "conv_sales"])
today_by_camp = today_by_camp.merge(conv_today_by_camp, on="campaign_name", how="left")
today_by_camp["conv_cost"] = today_by_camp["conv_cost"].fillna(0)
today_by_camp["conv_sales"] = today_by_camp["conv_sales"].fillna(0)
today_by_camp["roas"] = today_by_camp.apply(
lambda r: r["conv_sales"] / r["conv_cost"] if r["conv_cost"] > 0 else None, axis=1
)
ref_by_camp: dict[str, dict] = {}
if not ref_df.empty and "campaign_name" in ref_df.columns and "Cost" in ref_df.columns:
conv_ref = _conv_filter(ref_df)
for cname, grp in ref_df.groupby("campaign_name"):
daily_cost = grp.groupby("date")["Cost"].sum()
avg_cost = daily_cost.mean()
c_grp = conv_ref[conv_ref["campaign_name"] == cname] if not conv_ref.empty else pd.DataFrame()
if not c_grp.empty and "Cost" in c_grp.columns:
c_daily_cost = c_grp.groupby("date")["Cost"].sum()
c_daily_sales = c_grp.groupby("date")["Ads.Sales"].sum() if "Ads.Sales" in c_grp.columns else pd.Series(dtype=float)
avg_conv_cost = c_daily_cost.mean()
avg_conv_sales = c_daily_sales.mean() if not c_daily_sales.empty else 0
avg_roas = avg_conv_sales / avg_conv_cost if avg_conv_cost > 0 else None
else:
avg_roas = None
ref_by_camp[str(cname)] = {
"avg_cost": avg_cost,
"avg_roas": avg_roas,
}
for _, row in today_by_camp.iterrows():
cname = str(row["campaign_name"])
cost = float(row["cost"])
roas = row["roas"]
ref = ref_by_camp.get(cname, {})
avg_cost = ref.get("avg_cost", 0)
avg_roas = ref.get("avg_roas")
country = ""
platform = ""
funnel = ""
camp_rows = day_df[day_df["campaign_name"] == cname]
if not camp_rows.empty:
if "country" in camp_rows.columns:
country = str(camp_rows["country"].iloc[0])
if "platform" in camp_rows.columns:
platform = str(camp_rows["platform"].iloc[0])
if "campaign_type" in camp_rows.columns:
funnel = str(camp_rows["campaign_type"].iloc[0])
if avg_cost > 0 and cost > 200 and cost > avg_cost * 2.5:
chg = round((cost - avg_cost) / avg_cost * 100, 1)
campaign_alerts.append({
"type": "cost_spike",
"level": "red",
"campaign_name": cname,
"country": country,
"platform": platform,
"funnel": funnel,
"cost": round(cost, 2),
"avg7_cost": round(avg_cost, 2),
"change_pct": chg,
"message": f"花费暴涨 +{chg}%(${cost:,.0f} vs 7日均 ${avg_cost:,.0f})",
})
if avg_roas and avg_roas > 0 and roas is not None and cost > 100:
roas_chg = (roas - avg_roas) / avg_roas
if roas_chg < -0.5:
campaign_alerts.append({
"type": "roas_drop",
"level": "red",
"campaign_name": cname,
"country": country,
"platform": platform,
"funnel": funnel,
"roas": round(roas, 2),
"avg7_roas": round(avg_roas, 2),
"change_pct": round(roas_chg * 100, 1),
"message": f"ROAS 骤降 {roas_chg * 100:.0f}%({roas:.2f} vs 7日均 {avg_roas:.2f})",
})
today_names = set(today_by_camp["campaign_name"].astype(str))
for cname, ref in ref_by_camp.items():
if cname not in today_names and ref["avg_cost"] > 100:
ref_rows = ref_df[ref_df["campaign_name"] == cname]
country = ""
platform = ""
funnel = ""
if not ref_rows.empty:
if "country" in ref_rows.columns:
country = str(ref_rows["country"].iloc[0])
if "platform" in ref_rows.columns:
platform = str(ref_rows["platform"].iloc[0])
if "campaign_type" in ref_rows.columns:
funnel = str(ref_rows["campaign_type"].iloc[0])
campaign_alerts.append({
"type": "stopped",
"level": "yellow",
"campaign_name": cname,
"country": country,
"platform": platform,
"funnel": funnel,
"avg7_cost": round(ref["avg_cost"], 2),
"message": f"今日无花费(7日均 ${ref['avg_cost']:,.0f})",
})
campaign_alerts.sort(key=lambda a: (
0 if a["level"] == "red" else 1,
-a.get("cost", 0),
))
return campaign_alerts
def run_daily_monitor(config_path: Path, target_date: date | None = None, country: str | None = None) -> dict[str, Any]:
"""
对比 target_date 与前 7 天均值,检测异常波动。
country 为 None 或 "all" 时返回全部国家汇总。
"""
df = _load_enriched(config_path)
if country and country.lower() != "all" and "country" in df.columns:
df = df[df["country"] == country]
latest_in_data = df["date"].max().date() if not df.empty else None
if target_date is None:
yesterday = date.today() - timedelta(days=1)
target_date = min(yesterday, latest_in_data) if latest_in_data else yesterday
days_behind = (date.today() - latest_in_data).days if latest_in_data else None
stale_days = 2
if latest_in_data is None:
data_status = {"latest_date": None, "days_behind": None, "status": "missing"}
elif days_behind > stale_days:
data_status = {"latest_date": str(latest_in_data), "days_behind": days_behind, "status": "stale"}
else:
data_status = {"latest_date": str(latest_in_data), "days_behind": days_behind, "status": "ok"}
platform_freshness = {}
if not df.empty and "platform" in df.columns:
for plat in ("Meta", "Google", "Criteo", "Bing"):
p_df = df[df["platform"] == plat]
if p_df.empty:
platform_freshness[plat] = {"latest_date": None, "days_behind": None, "status": "missing"}
else:
p_latest = p_df["date"].max().date()
p_behind = (date.today() - p_latest).days
if p_behind > stale_days:
platform_freshness[plat] = {"latest_date": str(p_latest), "days_behind": p_behind, "status": "stale"}
else:
platform_freshness[plat] = {"latest_date": str(p_latest), "days_behind": p_behind, "status": "ok"}
data_status["by_platform"] = platform_freshness
target_ts = pd.Timestamp(target_date)
day_df = df[df["date"] == target_ts]
if day_df.empty:
return {
"date": str(target_date),
"data_status": data_status,
"alerts": [],
"summary": {},
"by_platform": {},
"error": f"没有 {target_date} 的数据",
}
window_start = target_ts - pd.Timedelta(days=7)
window_end = target_ts - pd.Timedelta(days=1)
ref_df = df[(df["date"] >= window_start) & (df["date"] <= window_end)]
total_today = {}
total_avg7 = {}
for m in MONITOR_METRICS:
if m in day_df.columns:
total_today[m] = day_df[m].sum() if m not in ("ROAS", "CTR", "CVR", "CPC") else None
if m not in ("ROAS", "CTR", "CVR", "CPC"):
total_today[m] = day_df[m].sum()
else:
total_today[m] = None
cost_today = day_df["Cost"].sum() if "Cost" in day_df.columns else 0
sales_today = day_df["Ads.Sales"].sum() if "Ads.Sales" in day_df.columns else 0
imp_today = day_df["Impressions"].sum() if "Impressions" in day_df.columns else 0
sessions_today = day_df["GA4.Sessions"].sum() if "GA4.Sessions" in day_df.columns else 0
atc_today = day_df["ATC"].sum() if "ATC" in day_df.columns else 0
clicks_today = 0
if "CTR" in day_df.columns and "Impressions" in day_df.columns:
clicks_today = (day_df["Impressions"].fillna(0) * day_df["CTR"].fillna(0)).sum()
conversions_today = 0
if "CVR" in day_df.columns and "GA4.Sessions" in day_df.columns:
conversions_today = (day_df["GA4.Sessions"].fillna(0) * day_df["CVR"].fillna(0)).sum()
total_today["Cost"] = cost_today
total_today["Ads.Sales"] = sales_today
total_today["ROAS"] = _conv_roas(day_df)
total_today["Impressions"] = imp_today
total_today["GA4.Sessions"] = sessions_today
total_today["ATC"] = atc_today
if "CPC" in day_df.columns:
total_today["CPC"] = cost_today / clicks_today if clicks_today > 0 else None
if "CTR" in day_df.columns:
total_today["CTR"] = clicks_today / imp_today if imp_today > 0 else None
if "CVR" in day_df.columns:
total_today["CVR"] = conversions_today / sessions_today if sessions_today > 0 else None
if not ref_df.empty:
daily_sums = ref_df.groupby("date")
n_days = daily_sums.ngroups or 1
for m in MONITOR_METRICS:
if m in ref_df.columns:
if m in ("CTR", "CVR"):
total_avg7[m] = ref_df.groupby("date")[m].mean().mean()
elif m == "ROAS":
conv_ref = _conv_filter(ref_df)
if not conv_ref.empty:
rc7 = conv_ref.groupby("date")["Cost"].sum().mean()
rs7 = conv_ref.groupby("date")["Ads.Sales"].sum().mean()
total_avg7[m] = rs7 / rc7 if rc7 > 0 else None
else:
total_avg7[m] = None
elif m == "CPC":
c7 = ref_df.groupby("date")["Cost"].sum().mean()
imp7 = ref_df.groupby("date")["Impressions"].sum().mean() if "Impressions" in ref_df.columns else 0
ctr7 = ref_df.groupby("date")["CTR"].mean().mean() if "CTR" in ref_df.columns else 0
clicks7 = imp7 * ctr7 if imp7 and ctr7 else 0
total_avg7[m] = c7 / clicks7 if clicks7 > 0 else None
else:
total_avg7[m] = ref_df.groupby("date")[m].sum().mean()
alerts = []
for m, cfg in MONITOR_METRICS.items():
today_val = total_today.get(m)
avg_val = total_avg7.get(m)
if today_val is None or avg_val is None or avg_val == 0:
continue
if today_val != today_val or avg_val != avg_val:
continue
change = (today_val - avg_val) / abs(avg_val)
if abs(change) < cfg["threshold"]:
continue
is_cost_metric = m in ("Cost", "CPC", "Impressions")
if m in ("ROAS", "Ads.Sales", "GA4.Sessions", "ATC", "CTR", "CVR"):
direction = "down" if change < 0 else "up"
level = "red" if (change < 0 and abs(change) >= cfg["threshold"] * 1.5) else \
"green" if change > 0 else "yellow"
else:
direction = "up" if change > 0 else "down"
level = "red" if (change > 0 and abs(change) >= cfg["threshold"] * 1.5) else \
"green" if change < 0 else "yellow"
alerts.append({
"metric": m,
"value": round(today_val, 4),
"value_fmt": _fmt_value(today_val, cfg["unit"]),
"avg7": round(avg_val, 4),
"avg7_fmt": _fmt_value(avg_val, cfg["unit"]),
"change_pct": round(change * 100, 1),
"level": level,
"direction": direction,
})
alerts.sort(key=lambda a: (0 if a["level"] == "red" else 1 if a["level"] == "yellow" else 2, -abs(a["change_pct"])))
by_platform = {}
for plat in ("Google", "Meta", "Criteo", "Bing"):
p_today = day_df[day_df["platform"] == plat] if "platform" in day_df.columns else pd.DataFrame()
p_ref = ref_df[ref_df["platform"] == plat] if "platform" in ref_df.columns else pd.DataFrame()
if p_today.empty:
continue
p_cost = p_today["Cost"].sum() if "Cost" in p_today.columns else 0
p_sales = p_today["Ads.Sales"].sum() if "Ads.Sales" in p_today.columns else 0
p_roas = _conv_roas(p_today)
p_data = {"Cost": p_cost, "Ads.Sales": p_sales, "ROAS": p_roas}
if not p_ref.empty:
c7 = p_ref.groupby("date")["Cost"].sum().mean() if "Cost" in p_ref.columns else 0
s7 = p_ref.groupby("date")["Ads.Sales"].sum().mean() if "Ads.Sales" in p_ref.columns else 0
conv_p_ref = _conv_filter(p_ref)
rc7 = conv_p_ref.groupby("date")["Cost"].sum().mean() if not conv_p_ref.empty and "Cost" in conv_p_ref.columns else 0
rs7 = conv_p_ref.groupby("date")["Ads.Sales"].sum().mean() if not conv_p_ref.empty and "Ads.Sales" in conv_p_ref.columns else 0
r7 = rs7 / rc7 if rc7 > 0 else None
p_data["avg7_Cost"] = round(c7, 2) if c7 else None
p_data["avg7_Sales"] = round(s7, 2) if s7 else None
p_data["avg7_ROAS"] = round(r7, 2) if r7 else None
if c7 > 0:
p_data["cost_chg"] = round((p_cost - c7) / c7 * 100, 1)
if s7 > 0:
p_data["sales_chg"] = round((p_sales - s7) / s7 * 100, 1)
if r7 and r7 > 0 and p_roas:
p_data["roas_chg"] = round((p_roas - r7) / r7 * 100, 1)
by_platform[plat] = p_data
summary_fmt = {}
for m in ("Cost", "Ads.Sales", "ROAS", "CPC", "CTR", "CVR", "GA4.Sessions", "ATC"):
v = total_today.get(m)
if v is not None and v == v:
summary_fmt[m] = _fmt_value(v, MONITOR_METRICS.get(m, {}).get("unit", ""))
hierarchy = _build_hierarchy(day_df, ref_df, full_df=df, target_date=target_date)
campaign_alerts = _build_campaign_alerts(day_df, ref_df)
return {
"date": str(target_date),
"data_status": data_status,
"alerts": alerts,
"campaign_alerts": campaign_alerts,
"summary": total_today,
"summary_fmt": summary_fmt,
"by_platform": by_platform,
"hierarchy": hierarchy,
}
def _agg_matrix_metrics(subset: pd.DataFrame) -> dict[str, Any]:
"""对一组行计算矩阵所需的 8 个聚合指标。ROAS 只用 Conversion 数据。"""
cost = subset["Cost"].sum() if "Cost" in subset.columns else 0
revenue = subset["Ads.Sales"].sum() if "Ads.Sales" in subset.columns else 0
impressions = int(subset["Impressions"].sum()) if "Impressions" in subset.columns else 0
clicks = 0.0
if "Impressions" in subset.columns and "CTR" in subset.columns:
clicks = (subset["Impressions"].fillna(0) * subset["CTR"].fillna(0)).sum()
conversions = 0.0
if "Impressions" in subset.columns and "CTR" in subset.columns and "CVR" in subset.columns:
row_clicks = subset["Impressions"].fillna(0) * subset["CTR"].fillna(0)
conversions = (row_clicks * subset["CVR"].fillna(0)).sum()
roas = _conv_roas(subset)
ctr = clicks / impressions if impressions > 0 else None
cpc = cost / clicks if clicks > 0 else None
cpm = cost / impressions * 1000 if impressions > 0 else None
cvr = conversions / clicks if clicks > 0 else None
return {
"cost": round(cost, 2),
"revenue": round(revenue, 2),
"roas": round(roas, 4) if roas is not None else None,
"clicks": int(round(clicks)),
"ctr": round(ctr, 4) if ctr is not None else None,
"cpc": round(cpc, 2) if cpc is not None else None,
"cpm": round(cpm, 2) if cpm is not None else None,
"cvr": round(cvr, 4) if cvr is not None else None,
}
def compute_trend_data(config_path: Path, start: date, end: date, country: str | None = None) -> dict[str, Any]:
"""
返回 [start, end] 区间内按日聚合的趋势数据,用于图表展示。
包含按漏斗、按渠道、按渠道×漏斗的花费分布以及 Conversion ROAS 折线。
country 为 None 或 "all" 时返回全部国家汇总。
"""
df = _load_enriched(config_path)
mask = (df["date"] >= pd.Timestamp(start)) & (df["date"] <= pd.Timestamp(end))
period = df[mask].copy()
if country and country.lower() != "all" and "country" in period.columns:
period = period[period["country"] == country]
countries = sorted(period["country"].dropna().unique()) if "country" in period.columns else []
if period.empty:
return {"dates": [], "by_funnel": {}, "by_platform": {}, "by_platform_funnel": {}, "roas": [], "countries": countries}
period["ds"] = period["date"].dt.strftime("%Y-%m-%d")
dates = sorted(period["ds"].unique())
funnels = ["Conversion", "Consideration", "Awareness"]
platforms = sorted(period["platform"].dropna().unique()) if "platform" in period.columns else []
by_funnel: dict[str, list[float]] = {}
piv_f = period.groupby(["ds", "campaign_type"])["Cost"].sum().unstack(fill_value=0)
for f in funnels:
by_funnel[f] = [round(float(piv_f.loc[d, f]), 2) if d in piv_f.index and f in piv_f.columns else 0 for d in dates]
by_platform: dict[str, list[float]] = {}
piv_p = period.groupby(["ds", "platform"])["Cost"].sum().unstack(fill_value=0)
for p in platforms:
by_platform[p] = [round(float(piv_p.loc[d, p]), 2) if d in piv_p.index and p in piv_p.columns else 0 for d in dates]
by_platform_funnel: dict[str, dict[str, list[float]]] = {}
piv_pf = period.groupby(["ds", "platform", "campaign_type"])["Cost"].sum()
for p in platforms:
by_platform_funnel[p] = {}
for f in funnels:
vals = []
for d in dates:
try:
vals.append(round(float(piv_pf.loc[(d, p, f)]), 2))
except KeyError:
vals.append(0)
by_platform_funnel[p][f] = vals
conv = period[period["campaign_type"] == "Conversion"]
conv_daily = conv.groupby("ds").agg(cost=("Cost", "sum"), sales=("Ads.Sales", "sum"))
roas_list = []
for d in dates:
if d in conv_daily.index and conv_daily.loc[d, "cost"] > 0:
roas_list.append(round(float(conv_daily.loc[d, "sales"] / conv_daily.loc[d, "cost"]), 2))
else:
roas_list.append(None)
# ── 数据矩阵:Country → Channel → Funnel → Date ──
matrix_total = _agg_matrix_metrics(period)
matrix: dict[str, Any] = {}
ctry_list = sorted(period["country"].dropna().unique()) if "country" in period.columns else []
for cty in ctry_list:
c_df = period[period["country"] == cty]
c_node: dict[str, Any] = {"m": _agg_matrix_metrics(c_df), "ch": {}}
plats = sorted(c_df["platform"].dropna().unique()) if "platform" in c_df.columns else []
for pl in plats:
p_df = c_df[c_df["platform"] == pl]
if p_df.empty:
continue
p_node: dict[str, Any] = {"m": _agg_matrix_metrics(p_df), "ch": {}}
for fn in funnels:
f_df = p_df[p_df["campaign_type"] == fn]
if f_df.empty:
continue
f_node: dict[str, Any] = {"m": _agg_matrix_metrics(f_df), "ch": {}}
for dt in dates:
d_df = f_df[f_df["ds"] == dt]
if d_df.empty:
continue
f_node["ch"][dt] = _agg_matrix_metrics(d_df)
p_node["ch"][fn] = f_node
c_node["ch"][pl] = p_node
matrix[cty] = c_node
return {
"dates": dates,
"by_funnel": by_funnel,
"by_platform": by_platform,
"by_platform_funnel": by_platform_funnel,
"roas": roas_list,
"countries": countries,
"matrix": matrix,
"matrix_total": matrix_total,
}
def _apply_filters(df: pd.DataFrame, filters: dict[str, str | None]) -> pd.DataFrame:
"""按筛选条件过滤数据行。filters 中值为 None / 'all' / '' 时不过滤。"""
for dim in FILTER_DIMS:
val = filters.get(dim)
if val and val.lower() != "all" and dim in df.columns:
df = df[df[dim] == val]
return df
def get_filter_options(config_path: Path) -> dict[str, list[str]]:
"""返回四个筛选维度的可选值列表(从数据中动态提取)。"""
df = _load_enriched(config_path)
result: dict[str, list[str]] = {}
for dim in FILTER_DIMS:
if dim in df.columns:
vals = sorted(df[dim].dropna().astype(str).unique())
vals = [v for v in vals if v.strip()]
result[dim] = vals
else:
result[dim] = []
return result
def _agg_summary(period: pd.DataFrame) -> dict[str, Any]:
"""对一段区间数据计算汇总 KPI。ROAS 只用 Conversion 数据。"""
cost = period["Cost"].sum() if "Cost" in period.columns else 0
sales = period["Ads.Sales"].sum() if "Ads.Sales" in period.columns else 0
imp = period["Impressions"].sum() if "Impressions" in period.columns else 0
sessions = period["GA4.Sessions"].sum() if "GA4.Sessions" in period.columns else 0
atc = period["ATC"].sum() if "ATC" in period.columns else 0
roas = _conv_roas(period)
clicks = 0.0
if "Impressions" in period.columns and "CTR" in period.columns:
clicks = (period["Impressions"].fillna(0) * period["CTR"].fillna(0)).sum()
conversions = 0.0
if "CVR" in period.columns and "GA4.Sessions" in period.columns:
conversions = (period["GA4.Sessions"].fillna(0) * period["CVR"].fillna(0)).sum()
return {
"Cost": round(cost, 2),
"Ads.Sales": round(sales, 2),
"ROAS": round(roas, 4) if roas else None,
"Impressions": int(imp),
"GA4.Sessions": round(sessions, 2),
"ATC": round(atc, 2),
"CPC": round(cost / clicks, 2) if clicks > 0 else None,
"CTR": round(clicks / imp, 4) if imp > 0 else None,
"CVR": round(conversions / sessions, 4) if sessions > 0 else None,
}
def _build_product_breakdown(period: pd.DataFrame) -> list[dict[str, Any]]:
"""按 产品线 -> 型号 聚合各 funnel 的 Cost / Sales / ROAS。"""
if "product_line" not in period.columns:
return []
funnels = ["Conversion", "Consideration", "Awareness"]
breakdown: list[dict[str, Any]] = []
for pl, pl_df in period.groupby("product_line"):
pl_str = str(pl)
if not pl_str or pl_str == "None":
continue
pl_cost = pl_df["Cost"].sum() if "Cost" in pl_df.columns else 0
pl_sales = pl_df["Ads.Sales"].sum() if "Ads.Sales" in pl_df.columns else 0
pl_roas = _conv_roas(pl_df)
funnel_split: dict[str, float] = {}
for fn in funnels:
fn_cost = pl_df[pl_df["campaign_type"] == fn]["Cost"].sum() if "campaign_type" in pl_df.columns else 0
funnel_split[fn] = round(fn_cost, 2)
models: list[dict[str, Any]] = []
if "product_model" in pl_df.columns:
for pm, pm_df in pl_df.groupby("product_model"):
pm_str = str(pm)
if not pm_str or pm_str == "None":
continue
pm_cost = pm_df["Cost"].sum() if "Cost" in pm_df.columns else 0
pm_sales = pm_df["Ads.Sales"].sum() if "Ads.Sales" in pm_df.columns else 0
pm_roas = _conv_roas(pm_df)
pm_funnel: dict[str, float] = {}
for fn in funnels:
fn_cost = pm_df[pm_df["campaign_type"] == fn]["Cost"].sum() if "campaign_type" in pm_df.columns else 0
pm_funnel[fn] = round(fn_cost, 2)
models.append({
"name": pm_str,
"Cost": round(pm_cost, 2),
"Ads.Sales": round(pm_sales, 2),
"ROAS": round(pm_roas, 4) if pm_roas else None,
"by_funnel": pm_funnel,
})
models.sort(key=lambda x: x["Cost"], reverse=True)
breakdown.append({
"name": pl_str,
"Cost": round(pl_cost, 2),
"Ads.Sales": round(pl_sales, 2),
"ROAS": round(pl_roas, 4) if pl_roas else None,
"by_funnel": funnel_split,
"models": models,
})
breakdown.sort(key=lambda x: x["Cost"], reverse=True)
return breakdown
def compute_dashboard_data(
config_path: Path,
start: date,
end: date,
filters: dict[str, str | None] | None = None,
) -> dict[str, Any]:
"""
数据看板:返回选定区间 + 筛选条件下的汇总 KPI、趋势、产品线看板、矩阵。
"""
df = _load_enriched(config_path)
mask = (df["date"] >= pd.Timestamp(start)) & (df["date"] <= pd.Timestamp(end))
period = df[mask].copy()
if filters:
period = _apply_filters(period, filters)
countries = sorted(period["country"].dropna().unique()) if "country" in period.columns else []
if period.empty:
return {
"start": str(start), "end": str(end),
"summary": {}, "dates": [], "by_funnel": {}, "by_platform": {},
"roas": [], "countries": countries, "product_breakdown": [],
"matrix": {}, "matrix_total": {},
}
summary = _agg_summary(period)
period["ds"] = period["date"].dt.strftime("%Y-%m-%d")
dates = sorted(period["ds"].unique())
funnels = ["Conversion", "Consideration", "Awareness"]
platforms = sorted(period["platform"].dropna().unique()) if "platform" in period.columns else []
by_funnel: dict[str, list[float]] = {}
piv_f = period.groupby(["ds", "campaign_type"])["Cost"].sum().unstack(fill_value=0)
for f in funnels:
by_funnel[f] = [round(float(piv_f.loc[d, f]), 2) if d in piv_f.index and f in piv_f.columns else 0 for d in dates]
by_platform: dict[str, list[float]] = {}
piv_p = period.groupby(["ds", "platform"])["Cost"].sum().unstack(fill_value=0)
for p in platforms:
by_platform[p] = [round(float(piv_p.loc[d, p]), 2) if d in piv_p.index and p in piv_p.columns else 0 for d in dates]
conv = period[period["campaign_type"] == "Conversion"]
conv_daily = conv.groupby("ds").agg(cost=("Cost", "sum"), sales=("Ads.Sales", "sum"))
roas_list: list[float | None] = []
for d in dates:
if d in conv_daily.index and conv_daily.loc[d, "cost"] > 0:
roas_list.append(round(float(conv_daily.loc[d, "sales"] / conv_daily.loc[d, "cost"]), 2))
else:
roas_list.append(None)
product_breakdown = _build_product_breakdown(period)
matrix_total = _agg_matrix_metrics(period)
matrix: dict[str, Any] = {}
for cty in countries:
c_df = period[period["country"] == cty]
c_node: dict[str, Any] = {"m": _agg_matrix_metrics(c_df), "ch": {}}
plats = sorted(c_df["platform"].dropna().unique()) if "platform" in c_df.columns else []
for pl in plats:
p_df = c_df[c_df["platform"] == pl]
if p_df.empty:
continue
p_node: dict[str, Any] = {"m": _agg_matrix_metrics(p_df), "ch": {}}
for fn in funnels:
f_df = p_df[p_df["campaign_type"] == fn]
if f_df.empty:
continue
f_node: dict[str, Any] = {"m": _agg_matrix_metrics(f_df), "ch": {}}
for dt in dates:
d_df = f_df[f_df["ds"] == dt]
if d_df.empty:
continue
f_node["ch"][dt] = _agg_matrix_metrics(d_df)
p_node["ch"][fn] = f_node
c_node["ch"][pl] = p_node
matrix[cty] = c_node
return {
"start": str(start),
"end": str(end),
"summary": summary,
"dates": dates,
"by_funnel": by_funnel,
"by_platform": by_platform,
"roas": roas_list,
"countries": countries,
"product_breakdown": product_breakdown,
"matrix": matrix,
"matrix_total": matrix_total,
}
def format_monitor_dingtalk(result: dict) -> tuple[str, str]:
"""将监控结果格式化为钉钉 Markdown 消息。"""
dt = result["date"]
ds = result.get("data_status", {})
alerts = result.get("alerts", [])
summary_fmt = result.get("summary_fmt", {})
by_platform = result.get("by_platform", {})
title = f"📡 数据监控 {dt}"
lines = [f"## 📡 数据监控 — {dt}\n"]
if ds.get("status") == "stale":
lines.append(f"> ⚠️ 数据已 {ds['days_behind']} 天未更新(最新: {ds['latest_date']})\n")
elif ds.get("status") == "missing":
lines.append("> ❌ 无法读取数据\n")
if alerts:
lines.append("### 异常指标\n")
for a in alerts:
icon = "🔴" if a["level"] == "red" else "🟢" if a["level"] == "green" else "🟡"
arrow = "↑" if a["direction"] == "up" else "↓"
lines.append(f"- {icon} **{a['metric']}**: {a['value_fmt']} ({arrow}{abs(a['change_pct'])}% vs 7日均值 {a['avg7_fmt']})")
lines.append("")
else:
lines.append("✅ 各指标均在正常波动范围内\n")
if summary_fmt:
lines.append("### 今日概览\n")
for k, v in summary_fmt.items():
label = MONITOR_LABEL_ZH.get(k, k)
lines.append(f"- **{label}** {v}")
lines.append("")
if by_platform:
lines.append("### 平台概览\n")
for plat, pd_ in by_platform.items():
lines.append(f"**{plat}**")
if "Cost" in pd_:
lines.append(f"- **花费** {_fmt_value(pd_['Cost'], '$')}")
if "Ads.Sales" in pd_:
lines.append(f"- **销售额** {_fmt_value(pd_['Ads.Sales'], '$')}")
if "ROAS" in pd_ and pd_["ROAS"]:
lines.append(f"- **ROAS** {pd_['ROAS']:.2f}")
lines.append("")
return title, "\n".join(lines)