Go to file
sheheryarsoomro12 54d41b12a4 Initial commit: PPC out-of-budget analyzer and dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:32:35 +05:00
data Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
ppcbudget Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
reports Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
tests Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
web Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
.gitignore Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
README.md Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
run_report.py Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00
serve.py Initial commit: PPC out-of-budget analyzer and dashboard. 2026-08-07 22:32:35 +05:00

README.md

PPC Out-of-Budget Analyzer

Finds the campaigns that keep running out of budget, how long they were dark, how often it happened, and what it plausibly cost.

Two ways to use it. Both run the same analysis code, so they can never disagree — the dashboard just makes it explorable and the report makes it shareable.

The dashboard

python3 serve.py

Opens http://localhost:8765. Drag your change-history exports onto the page. Sort and filter by any column, click a campaign for its full timeline and outage list, and download the Excel or CSV version from the header.

Add --preload to pick up whatever is already sitting in data/ on startup. The server binds to localhost only; nothing is uploaded anywhere.

The Excel report

python3 run_report.py

Put your exports in data/ first. The report lands in reports/. Nothing to install — it uses openpyxl, which you already have.

Loading more than one day

Drop in as many exports as you like, at once or over time. A single export can already cover a date range, and overlapping files are fine — rows appearing in more than one export are matched on entity, timestamp and values and counted once, which the Data Quality panel reports. Without that, an overlap doesn't corrupt the totals but it does bury the handful of genuine timeline contradictions under thousands of false ones.

With more than one day loaded the dashboard switches to one row per campaign, averaged across the days, with a strip showing one cell per day so you can see which days were bad. Click any campaign for its day-by-day breakdown: hours run, hours lost, outages and a 24-hour timeline for each individual day. Use the One row per day toggle to go back to the raw grain.

Don't mix marketplaces in one load — the account ROAS and spend behind the reality check come from the first export's metadata.

Getting dollar figures for every campaign

The change history records changes, so it only reveals a daily budget for campaigns whose budget someone edited that day — about 9% of them. Everything else gets exact timings but no dollar figure, and the report leaves those cells empty rather than guessing.

To price all of them, export a campaign performance report from the same Amazon Ads console (any report with Campaign, Spend, Sales and Budget columns). In the dashboard use the "Add report" slot; from the command line:

python3 run_report.py --perf ~/Downloads/campaign-report.xlsx

Column names are matched loosely, so most Amazon report variants work as-is. The Data Quality sheet reports how many campaigns matched, in both directions.

Options

run_report.py:

Flag Does
--perf FILE Join a performance report for full budget/ROAS coverage
--out FILE Write somewhere other than reports/
--roas N Override ROAS (defaults to the account average in the export)
--haircut N Discount on ROAS for incremental spend (default 0.7)
--cap N Cap lost spend at N × daily budget (default 3)
--merge-gap N Minutes in budget below which two outages count as one (default 5)

serve.py: --port N (default 8765), --preload, --no-browser. The same four modelling assumptions are editable live under Assumptions in the dashboard header.

You can also pass files or folders directly:

python3 run_report.py ~/Downloads/august-exports/

What the sheets show

Summary — a typical campaign's day (runs / lost / paused), the account-wide per-day figures, a reality check of modelled loss against actual spend, and the hour-by-hour starvation curve. That curve is usually the most useful thing in the file: it shows what share of the account is dark at each hour.

Campaigns — one row per campaign. With several days loaded it is averaged across them, with a column per day on the right shaded green through red so you can see which days broke. With a single day it carries the 24-hour heatmap instead.

Daily Detail — multi-day only. One row per campaign per day, with 24 narrow columns showing how many minutes of each hour the campaign was out of budget. Grey means paused, pale grey means the campaign did not exist yet.

Episodes — every individual outage with start and end times. Duration is wall-clock; Billable excludes minutes the campaign was paused during it.

Data Quality — every check that could change how much you trust the rest. Never hidden, never dismissible.

Method — how each number is calculated, in plain English.

Every sheet is a native Excel table, so the filter buttons and banding are already there. Durations are stored as real time values displayed as "23h 35min", so they still sum, sort and chart correctly.

Last meaningful action

Every campaign carries a Last action column: the most recent optimisation change inside the days the export covers — budget, bid, placement %, bidding strategy, targeting, enable/pause, or structural change. A campaign nobody has touched across the whole window reads "No action in 14 days" in red, and there is a No action taken filter and a headline count so you can pull the whole neglected set in one click.

The subtlety that makes this useful: Amazon's own pacing engine writes an In-budget/Out-of-budget row every time a campaign hits its cap — 2,639 of the 2,989 Campaign status rows in the reference file. Those are not counted as actions. If they were, every starving campaign would look actively managed, which is precisely backwards. Only the delivery half of that change type (Delivering/Paused) is a person. Renames are excluded too, for the same reason.

The window is always the span the data actually covers, never what you asked Amazon for. One day of export can only ever say "no action in 1 day".

The diagnoses

Label Means
Structurally underfunded Out of budget over half the day, and it started before noon
Exhausts early Burned through the budget before 9am
Pacing thrash Five or more separate outages without huge total loss — Amazon is releasing budget in slivers
Evening cap Only ran out after 6pm
Intermittent Out of budget, but no clear pattern
Healthy Out of budget under 5% of the day
Mostly paused Paused over half the day, so it forgoes nothing to budget and is excluded from loss

Two things worth knowing

Empty is not zero. Where no budget was observed, money cells are blank. A zero would become a fact the moment someone summed the column.

Paused time is excluded. A paused campaign isn't losing anything to its budget, so paused minutes are removed from both the loss total and the in-budget denominator that sets the spend rate.

Tests

python3 tests/test_golden.py

34 checks: frozen totals from the reference export, structural invariants, overlapping-export handling, action classification, and edge cases. The important ones are test_chain_breaks_canary and test_amazon_pacing_rows_are_not_actions.

The export is written newest-first, so rows sharing the same minute are also newest-first and must be reversed before the state machine walks them. Sorting on timestamp alone silently preserves the wrong order — it produces 19 chain breaks instead of 5, and reads one real 13-hour outage as a harmless 47-minute blip. That canary catches the regression.