DM-Agent / src /budget.py
wen Qiu
feat: add Campaign-level drill-down, anomaly alerts, and pacing curve
ec533d1
Raw History Blame Contribute Delete
10.8 kB
"""
预算追踪:从 Google Sheets 读取 Budget 工作表,
结合实际花费数据计算 pacing 和 KPI 对比。
Budget 工作表表头(宽格式,每行 = Month × Country):
Month, Country, SPF_Revenue_Target, DM_Revenue_Target,
Conversion_Budget, Consideration_Budget, Awareness_Budget,
DM_Total_Budget, Target_ROAS,
Conversion_%, Consideration_%, Awareness_%
"""
import calendar
import logging
from datetime import date, datetime
from pathlib import Path
from typing import Any
import pandas as pd
from src.loader import load_config, load_daily_data, _get_spreadsheet
log = logging.getLogger("ad-agent")
FUNNEL_COLS = {
"Conversion": "Conversion_Budget",
"Consideration": "Consideration_Budget",
"Awareness": "Awareness_Budget",
}
FUNNEL_PCT_COLS = {
"Conversion": "Conversion_%",
"Consideration": "Consideration_%",
"Awareness": "Awareness_%",
}
NUMERIC_COLS = [
"SPF_Revenue_Target", "DM_Revenue_Target",
"Conversion_Budget", "Consideration_Budget", "Awareness_Budget",
"DM_Total_Budget", "Target_ROAS",
]
PCT_COLS = ["Conversion_%", "Consideration_%", "Awareness_%"]
def _read_sheet(config: dict, name: str) -> pd.DataFrame | None:
try:
sh = _get_spreadsheet(config)
ws = sh.worksheet(name)
rows = ws.get_all_values()
if len(rows) < 2:
return None
return pd.DataFrame(rows[1:], columns=[c.strip() for c in rows[0]])
except Exception as e:
log.warning(f"Sheet [{name}] 读取失败: {e}")
return None
def load_budget_config(config: dict) -> pd.DataFrame | None:
df = _read_sheet(config, "Budget")
if df is None:
return None
for col in NUMERIC_COLS:
if col in df.columns:
cleaned = df[col].astype(str).str.replace(r"[$,\s]", "", regex=True)
df[col] = pd.to_numeric(cleaned, errors="coerce").fillna(0)
for col in PCT_COLS:
if col in df.columns:
cleaned = df[col].astype(str).str.replace(r"[%\s]", "", regex=True)
df[col] = pd.to_numeric(cleaned, errors="coerce").fillna(0)
return df
def _pacing_status(pacing: float | None) -> str:
if pacing is None:
return "no_data"
if pacing > 1.15:
return "overspending"
if pacing < 0.85:
return "underspending"
return "on_track"
def _safe_cost(df: pd.DataFrame) -> float:
if df.empty or "Cost" not in df.columns:
return 0.0
return float(df["Cost"].sum())
def _safe_revenue(df: pd.DataFrame) -> float:
if df.empty or "Ads.Sales" not in df.columns:
return 0.0
return float(df["Ads.Sales"].sum())
def _node(
budget: float,
spent: float,
pct_elapsed: float,
days_elapsed: int,
days_in_month: int,
) -> dict:
pct = spent / budget if budget > 0 else 0
pacing = pct / pct_elapsed if pct_elapsed > 0 else 0
projected = spent / days_elapsed * days_in_month if days_elapsed > 0 else 0
return {
"budget": round(budget, 2),
"spent": round(spent, 2),
"pct_used": round(pct * 100, 1),
"pacing": round(pacing, 2),
"pacing_status": _pacing_status(pacing if budget > 0 else None),
"projected": round(projected, 2),
}
def _compute_roas(actual_df: pd.DataFrame) -> float | None:
if actual_df.empty:
return None
cost = actual_df["Cost"].sum() if "Cost" in actual_df.columns else 0
sales = actual_df["Ads.Sales"].sum() if "Ads.Sales" in actual_df.columns else 0
if cost > 0:
return round(sales / cost, 2)
return None
def compute_budget_tracking(
config_path: Path | None = None,
target_month: str | None = None,
) -> dict[str, Any]:
config = load_config(config_path)
bcfg = config.get("budget", {})
if not bcfg.get("enabled"):
return {"error": "budget_disabled"}
budget_df = load_budget_config(config)
if budget_df is None or budget_df.empty:
return {"error": "no_budget_data"}
today = date.today()
if target_month:
try:
ym = datetime.strptime(target_month, "%Y-%m")
ty, tm = ym.year, ym.month
except ValueError:
return {"error": "bad_month"}
else:
ty, tm = today.year, today.month
ms = f"{ty}-{tm:02d}"
_, dim = calendar.monthrange(ty, tm)
if ty == today.year and tm == today.month:
de = max(today.day - 1, 1)
elif date(ty, tm, 1) > today:
de = 1
else:
de = dim
pe = de / dim
mb = budget_df[budget_df["Month"].astype(str).str.strip() == ms]
avail = sorted(budget_df["Month"].astype(str).str.strip().unique().tolist())
if mb.empty:
return {"error": "no_month_data", "available_months": avail, "month": ms}
try:
adf = load_daily_data(config_path)
except Exception as e:
return {"error": f"data_load_fail: {e}"}
m_start = pd.Timestamp(ty, tm, 1)
m_end = pd.Timestamp(ty, tm, dim)
am = adf[(adf["date"] >= m_start) & (adf["date"] <= m_end)].copy()
# ── Total level ──
tb = float(mb["DM_Total_Budget"].sum())
ts = _safe_cost(am)
total_rev = _safe_revenue(am)
am_conv = am[am["campaign_type"].astype(str).str.strip() == "Conversion"] if "campaign_type" in am.columns else am
total_roas_actual = _compute_roas(am_conv)
total_spf = float(mb["SPF_Revenue_Target"].sum())
total_dm_rev = float(mb["DM_Revenue_Target"].sum())
weights = mb[["Target_ROAS", "DM_Total_Budget"]].copy()
weights = weights[weights["Target_ROAS"] > 0]
if not weights.empty and weights["DM_Total_Budget"].sum() > 0:
total_target_roas = round(
(weights["Target_ROAS"] * weights["DM_Total_Budget"]).sum()
/ weights["DM_Total_Budget"].sum(), 2
)
else:
total_target_roas = None
nd = _node(tb, ts, pe, de, dim)
total_conv_rev = _safe_revenue(am_conv)
nd.update({
"month": ms,
"remaining": round(tb - ts, 2),
"days_elapsed": de,
"days_total": dim,
"days_remaining": dim - de,
"pct_elapsed": round(pe * 100, 1),
"available_months": avail,
"revenue_target": {
"spf": round(total_spf, 2),
"dm": round(total_dm_rev, 2),
},
"actual_revenue": round(total_conv_rev, 2),
"target_roas": total_target_roas,
"kpi": {},
})
if total_roas_actual is not None:
kpi_roas: dict[str, Any] = {"actual": total_roas_actual}
if total_target_roas and total_target_roas > 0:
kpi_roas["target"] = total_target_roas
kpi_roas["diff_pct"] = round(
(total_roas_actual - total_target_roas) / total_target_roas * 100, 1
)
nd["kpi"]["ROAS"] = kpi_roas
# ── Country → Funnel → Platform ──
hier: dict[str, Any] = {}
for country in sorted(mb["Country"].astype(str).str.strip().unique()):
crow = mb[mb["Country"].astype(str).str.strip() == country]
ca = (
am[am["country"].astype(str).str.strip() == country]
if "country" in am.columns
else pd.DataFrame()
)
c_budget = float(crow["DM_Total_Budget"].sum())
c_spent = _safe_cost(ca)
c_rev = _safe_revenue(ca)
ca_conv = ca[ca["campaign_type"].astype(str).str.strip() == "Conversion"] if not ca.empty and "campaign_type" in ca.columns else ca
c_roas_actual = _compute_roas(ca_conv)
c_target_roas = float(crow["Target_ROAS"].iloc[0]) if len(crow) > 0 else None
cn = _node(c_budget, c_spent, pe, de, dim)
cn["revenue_target"] = {
"spf": round(float(crow["SPF_Revenue_Target"].sum()), 2),
"dm": round(float(crow["DM_Revenue_Target"].sum()), 2),
}
c_conv_rev = _safe_revenue(ca_conv)
cn["actual_revenue"] = round(c_conv_rev, 2)
cn["target_roas"] = round(c_target_roas, 2) if c_target_roas else None
kpi_c: dict[str, Any] = {}
if c_roas_actual is not None:
kpi_entry: dict[str, Any] = {"actual": c_roas_actual}
if c_target_roas and c_target_roas > 0:
kpi_entry["target"] = round(c_target_roas, 2)
kpi_entry["diff_pct"] = round(
(c_roas_actual - c_target_roas) / c_target_roas * 100, 1
)
kpi_c["ROAS"] = kpi_entry
cn["kpi"] = kpi_c
# ── Funnel level ──
cn["by_funnel"] = {}
for funnel_name, budget_col in FUNNEL_COLS.items():
if budget_col not in crow.columns:
continue
f_budget = float(crow[budget_col].sum())
pct_col = FUNNEL_PCT_COLS.get(funnel_name, "")
f_budget_pct = float(crow[pct_col].iloc[0]) if pct_col in crow.columns and len(crow) > 0 else 0
fa = (
ca[ca["campaign_type"].astype(str).str.strip() == funnel_name]
if not ca.empty and "campaign_type" in ca.columns
else pd.DataFrame()
)
f_spent = _safe_cost(fa)
fn = _node(f_budget, f_spent, pe, de, dim)
fn["budget_pct"] = round(f_budget_pct, 1)
# ── Platform level (from actual data only) ──
fn["by_platform"] = {}
if not fa.empty and "platform" in fa.columns:
for plat in sorted(fa["platform"].astype(str).str.strip().unique()):
pa = fa[fa["platform"].astype(str).str.strip() == plat]
p_spent = _safe_cost(pa)
p_rev = _safe_revenue(pa)
p_roas = _compute_roas(pa)
fn["by_platform"][plat] = {
"spent": round(p_spent, 2),
"revenue": round(p_rev, 2),
"roas": p_roas,
}
cn["by_funnel"][funnel_name] = fn
hier[country] = cn
nd["by_country"] = hier
# ── 日度 pacing 曲线数据 ──
if not am.empty and "date" in am.columns:
daily_spend = am.groupby(am["date"].dt.day)["Cost"].sum().sort_index()
cum_actual = []
running = 0.0
pacing_dates = []
daily_ideal = tb / dim if dim > 0 else 0
cum_ideal = []
for day_num in range(1, dim + 1):
pacing_dates.append(f"{ms}-{day_num:02d}")
running += float(daily_spend.get(day_num, 0))
cum_actual.append(round(running, 2))
cum_ideal.append(round(daily_ideal * day_num, 2))
nd["pacing_chart"] = {
"dates": pacing_dates,
"cumulative_actual": cum_actual,
"cumulative_ideal": cum_ideal,
"budget_total": round(tb, 2),
}
return nd