Skip to main content

Is your ChatGPT ads test profitable? A small Python model with a sensitivity table

Updated 4 min readMarkdown

Before launching a paid test on a new ad channel, small teams usually ask "how much should we spend?". The more useful question is "at what cost per click does this stop making money?". This post builds a dependency-free Python model that answers both, then adds a sensitivity table so you can see which input actually moves the result.

The model

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Plan:
    monthly_budget: float
    avg_cpc: float
    cvr: float             # conversion rate, 0.03 = 3 %
    value: float           # revenue per conversion
    margin: float          # gross margin, 0.40 = 40 %
    goal: int = 0          # conversions wanted per month
    peak_factor: float = 2.0   # ASSUMPTION: worst single day vs average day

    @property
    def clicks(self): return self.monthly_budget / self.avg_cpc
    @property
    def conversions(self): return self.clicks * self.cvr
    @property
    def cpa(self): return self.monthly_budget / self.conversions
    @property
    def break_even_cpc(self): return self.value * self.margin * self.cvr
    @property
    def profit(self): return self.conversions * self.value * self.margin - self.monthly_budget

    def report(self):
        daily = self.monthly_budget / (365 / 12)
        return {
            "daily_budget": round(daily, 2),
            "clicks": round(self.clicks),
            "conversions": round(self.conversions, 1),
            "cpa": round(self.cpa, 2),
            "roas": round(self.conversions * self.value / self.monthly_budget, 2),
            "gross_profit": round(self.profit, 2),
            "break_even_cpc": round(self.break_even_cpc, 2),
            "budget_for_goal": round(self.goal * self.cpa, 2),
            "planning_peak_day": round(daily * self.peak_factor, 2),
        }

base = Plan(900, 2.50, 0.03, 250, 0.40, goal=20)
print(base.report())
{'daily_budget': 29.59, 'clicks': 360, 'conversions': 10.8, 'cpa': 83.33, 'roas': 3.0, 'gross_profit': 180.0, 'break_even_cpc': 3.0, 'budget_for_goal': 1666.67, 'planning_peak_day': 59.18}

A note on peak_factor: ad platforms often let a single day spend above the average daily budget, and the allowed amount differs by platform. The 2.0 here is our planning assumption, not a documented rule for any specific ad product. Set it from your platform's own help pages, or leave it at 2 and use it as a cash-flow buffer. Pacing behaviour is covered in more depth in our budget pacing guide.

Reading the output

  • Break-even CPC = value × margin × CVR. At €250 per sale, a 40% margin and 3% conversion, any click above €3.00 loses money. That single number is more useful than a budget target, because it tells you what to do when the auction gets expensive.
  • A 3× ROAS can still barely profit. At 40% margin, 3× ROAS leaves €180 a month. ROAS without margin is a vanity metric; the break-even ROAS guide shows the same idea from the other direction (break-even ROAS = 1 ÷ margin, so 2.5× here).
  • 10.8 conversions a month is about 2.5 a week. That's too few to judge a test in days. Plan it in weeks, and decide in advance how many conversions you need before you'll call it.

The sensitivity table

The model is linear in some inputs and not in others, so it's worth seeing them side by side. This varies each input by ±20% while holding the others at the base values:

def sensitivity(plan, fields=("avg_cpc", "cvr", "value", "margin"), step=0.2):
    rows = []
    for f in fields:
        for sign in (-1, 1):
            p = replace(plan, **{f: getattr(plan, f) * (1 + sign * step)})
            rows.append((f, f"{sign*step:+.0%}", round(p.profit, 0), round(p.break_even_cpc, 2)))
    return rows

print(f"{'input':<8}{'change':>7}{'profit €':>10}{'BE CPC €':>10}")
for r in sensitivity(base):
    print(f"{r[0]:<8}{r[1]:>7}{r[2]:>10}{r[3]:>10}")
input    change  profit €  BE CPC €
avg_cpc    -20%     450.0       3.0
avg_cpc    +20%       0.0       3.0
cvr        -20%     -36.0      2.4
cvr        +20%     396.0      3.6
value      -20%     -36.0      2.4
value      +20%     396.0      3.6
margin     -20%     -36.0      2.4
margin     +20%     396.0      3.6

Three things stand out:

  1. A 20% rise in CPC wipes out the whole profit. €2.50 + 20% is exactly the €3.00 break-even CPC, so the test has no headroom on price.
  2. CVR, value and margin act identically. They multiply together in both profit and break-even CPC, so a 20% gain in any one is worth the same. In practice CVR is usually the cheapest to move: it depends on your page and offer, not on the auction.
  3. Break-even CPC doesn't depend on CPC. Obvious once written down, but it's why break-even CPC makes a good bid ceiling: it stays fixed while the market moves.

Extending it

  • Add a weeks_to_n(n) helper, n / (self.conversions / (365/12/7)), to estimate how long a test takes to reach a given number of conversions.
  • Run sensitivity with step=0.5 to stress-test a launch plan.
  • Replace the single avg_cpc with a list of daily CPCs from your first week and recompute everything from real data.

The point is not precision. Writing the economics down as code makes the assumptions visible, including the ones that are only assumptions, like peak_factor.

No comments yet. Be the first to comment.