End-to-end workflow for the recurring "which price wins" decision on Meta Ads. The user almost always asks for the same three tests in one image; multi-city runs (3-4 cities in one session) are now standard.
- User sends one or more Meta Ads export xlsx files with the same campaign structure across cities (e.g.
Calgary-3-PRICES.xlsx,Denton-3-prices.xlsx). - Each file has 3 price variants tagged
(Price A2),(Price B),(Price C2),(Price D2), etc. — extract with regex. - User asks for 3 methods / three tests: profitability, SARIMAX 60-day, purchase rate, with bootstrap CIs.
| # | Test | What it measures | Method |
|---|---|---|---|
| 1 | Profitability ROAS | Return per $1 spent | Point = Σrevenue / Σspend. CI = day-level resample (n=3,500 or 10,000 per request), recompute ratio each iter, percentile(2.5, 97.5). |
| 2 | SARIMAX 60-day forecast | Trajectory of daily purchases | Auto-select order via pmdarima.auto_arima. Forecast 60 days forward, plot with 95% CI. |
| 3 | Purchase Rate (CVR) | Purchases / clicks | Point = Σpurchases / Σclicks. CI = day-level resample, recompute ratio, percentile(2.5, 97.5). |
Bootstrap count is task-specific — user has used 10,000 in one session and 3,500 in another. Always read the count from the current request; do not hard-code.
Pitfall #1 (this bites every time): Meta's Raw Data Report sheet has:
- Row 1: a band/title row with merged cells (mostly
Unnamed: N) - Row 2: the actual column NAMES —
Campaign name,Day,Amount spent (USD),Impressions,Purchases,Link clicks,Result rate,Results value, etc. - Row 3+: the data
Because the file has an extra band-row before the real header, pd.read_excel(header=1) skips that band but the column NAMES in the header row are at logical positions 0..N (with Campaign name at logical index 1, not 0), and the integer index used to access the underlying columns in df.iloc[:, k] is also offset by 0 from logical position — so what you read in df.iloc[:, 8] is NOT Amount spent (USD), it is the 9th column of the dataframe which is one column to the right of what the header label suggests.
Concrete column indices that work (verified across all 4 cities in the 2026-06-02 session):
| Field | df.iloc[:, k] |
Header label (logical pos) |
|---|---|---|
| Campaign name | 1 | 1 |
| Day (date) | 2 | 2 |
| Spend (Amount spent USD) | 8 | 8 |
| Impressions | 9 | 9 |
| Purchases (Results) | 13 | 13 |
| Purchase ROAS | 19 | 19 |
| Link clicks | 20 | 20 |
| CTR (all) | 21 | 21 |
| Result rate (CVR) | 22 | 22 |
| Results value (REVENUE) | 24 | 24 |
| Results ROAS | 26 | 26 |
| Reporting starts | 27 | 27 |
| Reporting ends | 28 | 28 |
Symptom when you get it wrong: revenue sum = $0 for every campaign. That's the tell. Always sum revenue first as a sanity check; if you see 0, you've used the wrong column index.
Reproduction recipe: See references/meta_export_layout.md.
Auto-select is non-trivial because training-data length varies wildly (Waukesha = 121 days, Edmonton = 33 days, Denton = 20 days). Rules:
- Seasonal cutoff: Use SARIMA only if training data has ≥ 28 days (≥ 2 full weekly cycles for m=7) AND ACF at lag 7 is significant (|acf[7]| > 2/√n — Bartlett CI). Otherwise fall back to plain ARIMA.
- Differencing (d): ADF test on the first-differenced series;
d = 1if p > 0.05, elsed = 0. - Auto-arima config:
seasonal=use_seasonal,m=7if seasonal else 1d=d,D=1if seasonal else 0max_p=3, max_q=3, max_P=2, max_Q=2stepwise=True,information_criterion='aic'
- Fallback: If
auto_arimaraises, fit SARIMAX(1, d, 1) × (1, 1, 1, 7) or ARIMAX(1, d, 1) directly. - Forecast horizon: 60 days forward from end of training data. Flag with
⚠ forecast horizon (60d) > training data (Nd)when N < 60 — extrapolation is unreliable.
For ratio metrics (ROAS, CVR) the wrong way is to bootstrap each day's ratio then take mean of means (that understates uncertainty). The right way:
point = sum(numerator_daily) / sum(denominator_daily)
for b in 1..B:
idx = random_choice_with_replacement(range(n_days), n_days)
boot[b] = sum(numerator[idx]) / sum(denominator[idx])
ci = percentile(boot, [2.5, 97.5])
This is day-level cluster bootstrap of the ratio, which is what you want for a campaign-level metric.
3-panel horizontal layout, one PNG per city:
| Test 1: ROAS bars | Test 3: CVR bars | Test 2: SARIMAX 60-day |
| + 95% bootstrap CI | + 95% bootstrap CI | overlay of all 3 prices |
| winner starred | winner starred | solid=history, dashed= |
| | | forecast, shaded=95% CI |
Style rules (per user preference, hard-coded in the template):
- Horizontal 3-panel, figsize ≈ (20, 6.5), dpi 140.
- Bar value labels: ≥ 15 pt bold, centered above bar.
- CI labels: ≥ 9–10 pt, below the lower error bar.
- One color per price, consistent across all 3 panels (A2=blue, B=green, C2=orange, D2=red).
- "★ WINNER" annotation above the best bar in each of panels 1 & 2.
- SARIMA panel legend lists each price with its fitted model label (e.g.
Price A2 — SARIMA(1,1,1)(1,1,1,7)). - Title is
{CITY} — 3-Price Campaign Winner Analysis, 20pt bold. - Save to
/root/.hermes/output/city_3price/{City}.png.
Reusable code: see templates/analyze_template.py — copy, change FILES dict, run. Whole pipeline takes ~30-60s per city.
One paragraph, ~3-5 sentences, in this order:
- State the winner per test (ROAS winner / CVR winner / SARIMAX winner).
- Note whether all 3 tests agree, and on what (e.g. "all three point to A2").
- One quantitative anchor (e.g. "A2 ROAS 3.50× vs C2 3.41× vs D2 3.18× — A2 is +0.09× over C2, well inside the CIs").
- Caveat: training-data length, forecast reliability, whether CIs overlap.
Deliver visualizations first, text second (per Pouya's standing preference — also captured in memory). Use MEDIA:/root/.hermes/output/city_3price/{City}.png to inline-send.
⚠ Cross-platform mirroring pitfall (captured 2026-06-02): When this chat session is mirrored to another platform (TUI ↔ Telegram), the MEDIA: inline path and the send_message tool both may not render as a visible image on the receiving end, even when the tool returns a successful message_id. Symptom: user says "This is text / Where are the pngs?" or "I don't see zip" repeatedly. Do NOT retry the same delivery mechanism twice — pivot on the first complaint to a guaranteed fallback (ZIP with a pull-hosted path, base64 in a code block, or ask the user which channel to use). The 3-retry loop on a broken delivery path is one of Pouya's top frustrations.
- P1 — Column index trap: see §3. Symptom: revenue = $0. Fix: use indices 8/9/13/20/24 for spend/impressions/purchases/clicks/revenue.
- P2 — Stale bootstrap count: don't hard-code 10,000; read from the request.
- P3 — SARIMA on too-short series: if data < 28 days, don't force SARIMA. If horizon (60) > training N, flag it explicitly.
- P4 — Bootstrap of ratio metrics: resample DAYS (not rows from the numerator alone); recompute the ratio each iter.
- P5 —
Unnamed: 0confusion: the dataframe has more integer columns than the user thinks. Alwaysdf.head(1).to_string()to see the actual layout before coding the loader. - P6 — Currency: revenue is in dollars, not thousands. Don't divide by 1000.
- P7 — Aggregate row at top: Meta's export has an "All" row right after the header. The day-detection regex (
pd.to_datetime(df['c2'], errors='coerce').notna()) filters it out correctly because "All" is not a date — but if you change the loader, re-verify. - P8 — Filename casing is messy:
Denton-3-prices.xlsx,Calgary-3-PRICES.xlsx,Edmonton.xlsx(no "3-prices" suffix!),Waukesha-3-prices.xlsx. The loader keys are hard-coded — don't try to glob.
The complete runnable template is at templates/analyze_template.py. Workflow:
# 1. Inspect a file's layout (one-liner)
python3 -c "import pandas as pd; df=pd.read_excel(FILE, header=None); print(df.iloc[2].tolist())"
# 2. Edit FILES dict in templates/analyze_template.py, set N_BOOT from request
# 3. Run
python3 analyze_template.py
# → 4 PNGs in /root/.hermes/output/city_3price/{City}.png
# → console summary tablereferences/meta_export_layout.md— the xlsx layout in detail + a copy-paste loader.references/sarimax_model_selection.md— the auto-selection rules + ACF significance test + worked example.references/bootstrap_on_ratios.md— why day-level resample, why NOT row-level.templates/analyze_template.py— the full self-contained runnable script.