Systems Library / Marketing Automation / How to Build an Ad Fatigue Detection System
Marketing Automation paid advertising

How to Build an Ad Fatigue Detection System

Detect ad creative fatigue automatically and alert before performance drops.

Jay Banlasan

Jay Banlasan

The AI Systems Guy

An automated ad fatigue detection system catches declining performance before it costs you money. I run this on every active account. It tracks three signals: rising frequency, falling CTR, and increasing CPA. When two out of three trigger, the ad gets flagged.

Most advertisers only notice fatigue when performance has already dropped 30%+. This system catches it at 10%.

What You Need Before Starting

Step 1: Define Fatigue Signals

FATIGUE_CONFIG = {
    "frequency_warning": 2.5,
    "frequency_critical": 4.0,
    "ctr_decline_warning": 0.15,
    "ctr_decline_critical": 0.30,
    "cpa_increase_warning": 0.20,
    "cpa_increase_critical": 0.40,
    "min_spend_to_evaluate": 20,
    "lookback_days": 14,
    "comparison_window": 7,
}

Step 2: Calculate Fatigue Metrics

import sqlite3
from datetime import datetime, timedelta

def calculate_fatigue_metrics(db_path, ad_id):
    conn = sqlite3.connect(db_path)
    config = FATIGUE_CONFIG
    
    rows = conn.execute("""
        SELECT date, spend, impressions, clicks, reach, leads
        FROM ad_daily
        WHERE ad_id = ? AND date >= DATE('now', ? || ' days')
        ORDER BY date
    """, (ad_id, f"-{config['lookback_days']}")).fetchall()
    conn.close()
    
    if len(rows) < config["comparison_window"]:
        return None
    
    midpoint = len(rows) // 2
    first_half = rows[:midpoint]
    second_half = rows[midpoint:]
    
    def avg_metric(data, idx):
        vals = [r[idx] for r in data if r[idx] and r[idx] > 0]
        return sum(vals) / len(vals) if vals else 0
    
    first_impressions = sum(r[2] for r in first_half)
    first_clicks = sum(r[3] for r in first_half)
    first_reach = sum(r[4] for r in first_half) or 1
    first_spend = sum(r[1] for r in first_half)
    first_leads = sum(r[5] for r in first_half)
    
    second_impressions = sum(r[2] for r in second_half)
    second_clicks = sum(r[3] for r in second_half)
    second_reach = sum(r[4] for r in second_half) or 1
    second_spend = sum(r[1] for r in second_half)
    second_leads = sum(r[5] for r in second_half)
    
    first_ctr = (first_clicks / first_impressions * 100) if first_impressions > 0 else 0
    second_ctr = (second_clicks / second_impressions * 100) if second_impressions > 0 else 0
    
    first_freq = first_impressions / first_reach
    second_freq = second_impressions / second_reach
    
    first_cpa = first_spend / first_leads if first_leads > 0 else 999
    second_cpa = second_spend / second_leads if second_leads > 0 else 999
    
    ctr_change = (second_ctr - first_ctr) / first_ctr if first_ctr > 0 else 0
    cpa_change = (second_cpa - first_cpa) / first_cpa if first_cpa > 0 and first_cpa < 999 else 0
    
    return {
        "ad_id": ad_id,
        "current_frequency": round(second_freq, 2),
        "ctr_change": round(ctr_change, 3),
        "cpa_change": round(cpa_change, 3),
        "first_ctr": round(first_ctr, 2),
        "second_ctr": round(second_ctr, 2),
        "first_cpa": round(first_cpa, 2),
        "second_cpa": round(second_cpa, 2),
        "total_spend": first_spend + second_spend,
    }

Step 3: Score Fatigue Level

def score_fatigue(metrics):
    if not metrics or metrics["total_spend"] < FATIGUE_CONFIG["min_spend_to_evaluate"]:
        return {"level": "insufficient_data", "score": 0, "signals": []}
    
    signals = []
    score = 0
    
    # Frequency signal
    if metrics["current_frequency"] >= FATIGUE_CONFIG["frequency_critical"]:
        signals.append(f"Frequency critical: {metrics['current_frequency']}")
        score += 3
    elif metrics["current_frequency"] >= FATIGUE_CONFIG["frequency_warning"]:
        signals.append(f"Frequency warning: {metrics['current_frequency']}")
        score += 1
    
    # CTR decline signal
    if metrics["ctr_change"] <= -FATIGUE_CONFIG["ctr_decline_critical"]:
        signals.append(f"CTR dropped {abs(metrics['ctr_change'])*100:.0f}% ({metrics['first_ctr']}% to {metrics['second_ctr']}%)")
        score += 3
    elif metrics["ctr_change"] <= -FATIGUE_CONFIG["ctr_decline_warning"]:
        signals.append(f"CTR declining {abs(metrics['ctr_change'])*100:.0f}%")
        score += 1
    
    # CPA increase signal
    if metrics["cpa_change"] >= FATIGUE_CONFIG["cpa_increase_critical"]:
        signals.append(f"CPA up {metrics['cpa_change']*100:.0f}% (${metrics['first_cpa']} to ${metrics['second_cpa']})")
        score += 3
    elif metrics["cpa_change"] >= FATIGUE_CONFIG["cpa_increase_warning"]:
        signals.append(f"CPA rising {metrics['cpa_change']*100:.0f}%")
        score += 1
    
    if score >= 5:
        level = "critical"
    elif score >= 3:
        level = "warning"
    elif score >= 1:
        level = "watch"
    else:
        level = "healthy"
    
    return {"level": level, "score": score, "signals": signals}

Step 4: Run Detection Across All Active Ads

def detect_fatigue_all(db_path):
    conn = sqlite3.connect(db_path)
    active_ads = conn.execute("""
        SELECT DISTINCT ad_id, ad_name FROM ad_daily
        WHERE date >= DATE('now', '-3 days') AND spend > 0
    """).fetchall()
    conn.close()
    
    results = {"critical": [], "warning": [], "watch": [], "healthy": []}
    
    for ad_id, ad_name in active_ads:
        metrics = calculate_fatigue_metrics(db_path, ad_id)
        fatigue = score_fatigue(metrics)
        fatigue["ad_name"] = ad_name
        fatigue["ad_id"] = ad_id
        results[fatigue["level"]].append(fatigue)
    
    return results

Step 5: Alert and Report

import requests
import os

def send_fatigue_report(results):
    critical = results.get("critical", [])
    warning = results.get("warning", [])
    
    if not critical and not warning:
        return
    
    message = "*Ad Fatigue Report*\n\n"
    
    if critical:
        message += "*CRITICAL (rotate now):*\n"
        for ad in critical:
            message += f"  {ad['ad_name']}: {', '.join(ad['signals'])}\n"
    
    if warning:
        message += "\n*WARNING (prepare replacement):*\n"
        for ad in warning:
            message += f"  {ad['ad_name']}: {', '.join(ad['signals'])}\n"
    
    requests.post(os.getenv("SLACK_WEBHOOK_URL"), json={"text": message})

if __name__ == "__main__":
    results = detect_fatigue_all("meta_ads.db")
    send_fatigue_report(results)
    print(f"Critical: {len(results['critical'])}, Warning: {len(results['warning'])}, Healthy: {len(results['healthy'])}")

Run this daily. Critical ads need replacement within 48 hours. Warning ads need a backup creative in the queue within a week.

What to Build Next

Connect this to your creative rotation system so fatigued ads get swapped automatically. Then build a fatigue prediction model that estimates when currently healthy ads will start declining.

Related Reading

Want this system built for your business?

Get a free assessment. We will map every system your business needs and show you the ROI.

Get Your Free Assessment

Related Systems