Spaces:
Runtime error
Runtime error
Download src/budget.py from BinbomJared/DM-Agent: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/BinbomJared/DM-Agent/resolve/main/src/budget.py
- Command line
-
hf download hf://spaces/BinbomJared/DM-Agent/src/budget.py
-
curl -L -o budget.py https://huggingface.co/spaces/BinbomJared/DM-Agent/resolve/main/src/budget.py
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 | |