Budget Pacing & Reallocation¶
The budget pacing engine monitors campaign spend against plan in real time, detects over-delivery and under-delivery, and proposes cross-channel budget reallocations mid-flight. After a campaign's deals are booked via the campaign pipeline, pacing answers three questions continuously: Are we on pace? What is off? What should we do about it?
The pacing engine is implemented by BudgetPacingEngine in ad_buyer.pacing.engine. Snapshots are persisted by PacingStore in ad_buyer.storage.pacing_store, and pacing events flow through the event bus.
How It Works¶
The engine uses a linear pacing model: expected spend at any point is proportional to the fraction of the flight window that has elapsed. It calculates pacing at three levels --- campaign, channel, and deal --- and generates reallocation recommendations when channels deviate from plan.
flowchart LR
Spend["Collect\nSpend Data"]
Calculate["Calculate\nExpected vs Actual"]
Detect["Detect\nDeviations"]
Recommend["Propose\nReallocations"]
Snapshot["Save\nSnapshot"]
Spend --> Calculate --> Detect --> Recommend --> Snapshot
Quick Example¶
from datetime import datetime, timezone
from ad_buyer.pacing.engine import BudgetPacingEngine, PacingConfig
from ad_buyer.storage.pacing_store import PacingStore
from ad_buyer.events.bus import InMemoryEventBus
# Setup
engine = BudgetPacingEngine(
config=PacingConfig(),
event_bus=InMemoryEventBus(),
)
store = PacingStore("sqlite:///./ad_buyer.db")
store.connect()
# Generate a pacing snapshot
snapshot = engine.generate_snapshot(
campaign_id="campaign-abc",
total_budget=150_000.0,
flight_start=datetime(2026, 7, 1, tzinfo=timezone.utc),
flight_end=datetime(2026, 9, 30, tzinfo=timezone.utc),
current_time=datetime(2026, 8, 15, tzinfo=timezone.utc),
channel_data={
"CTV": {"allocated_budget": 75_000, "spend": 30_000, "impressions": 1_200_000},
"DISPLAY": {"allocated_budget": 45_000, "spend": 28_000, "impressions": 2_800_000},
"AUDIO": {"allocated_budget": 30_000, "spend": 10_000, "impressions": 600_000},
},
deal_data=[
{"deal_id": "deal-001", "allocated_budget": 40_000, "spend": 16_000},
{"deal_id": "deal-002", "allocated_budget": 35_000, "spend": 14_000},
],
)
# Inspect results
print(f"Campaign pacing: {snapshot.pacing_pct:.1f}%")
print(f"Deviation: {snapshot.deviation_pct:+.1f}%")
for ch in snapshot.channel_snapshots:
print(f" {ch.channel}: {ch.pacing_pct:.1f}% paced, ${ch.spend:,.0f} spent")
for rec in snapshot.recommendations:
print(f" Recommend: move ${rec.amount:,.0f} from {rec.source_channel} to {rec.target_channel}")
# Persist the snapshot
store.save_pacing_snapshot(snapshot)
Pacing Calculations¶
Expected Spend (Linear Model)¶
Expected spend at any point in the campaign is:
The engine clamps the calculation to the flight window --- before the start date, expected spend is zero; after the end date, expected spend equals the total budget.
expected = engine.calculate_expected_spend(
total_budget=150_000.0,
flight_start=datetime(2026, 7, 1, tzinfo=timezone.utc),
flight_end=datetime(2026, 9, 30, tzinfo=timezone.utc),
current_time=datetime(2026, 8, 15, tzinfo=timezone.utc),
)
# expected ≈ 73,770 (about 49.2% of flight elapsed)
Pacing Percentage¶
Pacing percentage is (actual_spend / expected_spend) * 100:
| Value | Meaning |
|---|---|
| 100% | Exactly on pace |
| < 100% | Underpacing (spending slower than planned) |
| > 100% | Overpacing (spending faster than planned) |
pacing = engine.calculate_pacing_pct(actual_spend=68_000, expected_spend=73_770)
# pacing ≈ 92.2% (slightly underpacing)
Deviation Percentage¶
Deviation is pacing_pct - 100. Negative means underpacing, positive means overpacing:
deviation = engine.calculate_deviation_pct(actual_spend=68_000, expected_spend=73_770)
# deviation ≈ -7.8%
Deviation Detection¶
The engine detects pacing deviations at two severity levels, for both underpacing and overpacing:
| Direction | Warning Threshold | Critical Threshold |
|---|---|---|
| Underpacing | > 10% below expected | > 25% below expected |
| Overpacing | > 10% above expected | > 25% above expected |
Configuring thresholds
All thresholds are configurable via PacingConfig:
When a deviation exceeds a threshold, detect_deviation() returns a PacingAlert:
alert = engine.detect_deviation(actual_spend=50_000, expected_spend=73_770)
# alert.level = "warning"
# alert.direction = "underpacing"
# alert.deviation_pct ≈ -32.2
# alert.message = "Critical underpacing: -32.2% deviation (threshold: -25.0%)"
Alerts at the critical level indicate the campaign is severely off-pace and likely needs intervention. Warning-level alerts are informational --- the campaign is drifting but may self-correct.
Cross-Channel Budget Reallocation¶
When the pacing engine detects that some channels are underpacing while others are overpacing, it proposes budget reallocations --- shifting money from underperforming channels to channels that are spending efficiently.
How Proposals Are Generated¶
- For each channel, the engine calculates expected spend proportionally:
channel_expected = campaign_expected * (channel_budget / total_budget) - Channels deviating below the underpacing warning threshold are classified as sources (potential budget donors)
- Channels deviating above the overpacing warning threshold are classified as targets (budget recipients)
- For each source-target pair, the reallocation amount is the minimum of the source's underspend, the target's overspend, and the max reallocation cap
Reallocation Constraints¶
| Constraint | Default | Description |
|---|---|---|
min_reallocation_amount |
$100 | Amounts below this are not worth the operational cost |
max_reallocation_pct |
30% | Maximum percentage of total budget that can be reallocated in a single proposal |
config = PacingConfig(
min_reallocation_amount=500.0, # Only propose if $500+
max_reallocation_pct=20.0, # Cap at 20% of total budget
)
Proposal Data Model¶
Each ReallocationProposal contains:
| Field | Type | Description |
|---|---|---|
source_channel |
str |
Channel to take budget from (underpacing) |
target_channel |
str |
Channel to give budget to (overpacing) |
amount |
float |
Dollar amount to reallocate |
reason |
str |
Human-readable justification |
proposals = engine.propose_reallocations(
channel_snapshots=snapshot.channel_snapshots,
total_budget=150_000.0,
expected_spend=73_770.0,
)
for p in proposals:
print(f"Move ${p.amount:,.0f} from {p.source_channel} to {p.target_channel}")
print(f" Reason: {p.reason}")
Proposals require approval
Reallocation proposals are recommendations, not automatic actions. By default, the PACING_ADJUSTMENT approval stage is disabled, meaning proposals can be applied automatically. Enable it in the campaign brief's approval_config to require human sign-off before budget is moved.
Deal-Level Pacing¶
In addition to campaign and channel-level pacing, the engine tracks pacing for individual deals:
from ad_buyer.models.campaign import DealSnapshot
deal_pacing = engine.calculate_deal_pacing(
deal_snapshot=DealSnapshot(
deal_id="deal-001",
allocated_budget=40_000,
spend=16_000,
impressions=640_000,
effective_cpm=25.0,
),
flight_start=datetime(2026, 7, 1, tzinfo=timezone.utc),
flight_end=datetime(2026, 9, 30, tzinfo=timezone.utc),
current_time=datetime(2026, 8, 15, tzinfo=timezone.utc),
)
print(f"Deal {deal_pacing['deal_id']}: {deal_pacing['pacing_pct']:.1f}% paced")
print(f" Expected: ${deal_pacing['expected_spend']:,.0f}")
print(f" Actual: ${deal_pacing['actual_spend']:,.0f}")
if deal_pacing["alert"]:
print(f" Alert: {deal_pacing['alert'].message}")
Pacing Snapshots¶
A PacingSnapshot is a point-in-time capture of the entire campaign's pacing state. The generate_snapshot() method produces one by computing pacing at all three levels and generating any applicable recommendations.
Snapshot Data Model¶
class PacingSnapshot(BaseModel):
snapshot_id: str # UUID
campaign_id: str
timestamp: datetime
total_budget: float
total_spend: float
pacing_pct: float # Campaign-level pacing %
expected_spend: float
deviation_pct: float # Positive = overpacing, negative = underpacing
channel_snapshots: list[ChannelSnapshot]
deal_snapshots: list[DealSnapshot]
recommendations: list[PacingRecommendation]
Each ChannelSnapshot captures:
| Field | Type | Description |
|---|---|---|
channel |
str |
Channel name (CTV, DISPLAY, etc.) |
allocated_budget |
float |
Budget allocated to this channel |
spend |
float |
Actual spend to date |
pacing_pct |
float |
Channel pacing percentage |
impressions |
int |
Impressions delivered |
effective_cpm |
float |
Blended CPM |
fill_rate |
float |
Fill rate (0.0 to 1.0) |
Each DealSnapshot adds deal_id, win_rate, and the same spend/impression metrics.
Persistence¶
Snapshots are persisted via PacingStore, a SQLite-backed store with the same thread-safety pattern as DealStore:
from ad_buyer.storage.pacing_store import PacingStore
store = PacingStore("sqlite:///./ad_buyer.db")
store.connect()
# Save
store.save_pacing_snapshot(snapshot)
# Retrieve latest
latest = store.get_latest_pacing_snapshot("campaign-abc")
# List with time filter
from datetime import datetime, timezone
snapshots = store.list_pacing_snapshots(
campaign_id="campaign-abc",
start_time=datetime(2026, 8, 1, tzinfo=timezone.utc),
end_time=datetime(2026, 8, 15, tzinfo=timezone.utc),
)
The pacing_snapshots table is indexed on campaign_id and timestamp for efficient time-series queries.
Events Emitted¶
The pacing engine emits three event types:
| Event | When | Payload |
|---|---|---|
pacing.snapshot_taken |
After every snapshot generation | snapshot_id, budget/spend/pacing metrics, channel and deal counts |
pacing.deviation_detected |
When campaign-level deviation exceeds a threshold | alert_level, direction, deviation_pct, message |
pacing.reallocation_recommended |
For each reallocation proposal | source_channel, target_channel, amount, reason |
Subscribe to these events to build dashboards, trigger alerts, or automate reallocation approval workflows.
Integration with the Campaign Lifecycle¶
Budget pacing operates on campaigns that have reached ACTIVE status. The typical flow:
- Campaign pipeline books deals and moves campaign to READY
- Campaign is activated (manually or on flight start date) --- status becomes ACTIVE
- Pacing engine begins generating snapshots at regular intervals
- If deviation exceeds the critical threshold, the state machine can transition the campaign to PACING_HOLD --- an automated hold distinct from manual PAUSED
- When deviation resolves, PACING_HOLD auto-transitions back to ACTIVE
- If it does not resolve, it can be escalated to PAUSED for manual intervention
Configuration Reference¶
PacingConfig controls all engine behavior:
| Parameter | Default | Description |
|---|---|---|
underpacing_warning_pct |
10.0 | Warning threshold for underpacing |
underpacing_critical_pct |
25.0 | Critical threshold for underpacing |
overpacing_warning_pct |
10.0 | Warning threshold for overpacing |
overpacing_critical_pct |
25.0 | Critical threshold for overpacing |
min_reallocation_amount |
100.0 | Minimum amount for a reallocation proposal |
max_reallocation_pct |
30.0 | Max percentage of total budget per proposal |
Related¶
- Campaign Brief to Deal Pipeline --- Initial campaign setup (pacing monitors what the pipeline books)
- Deals API --- Deal status and modification endpoints
- Multi-Seller Orchestration --- Cross-seller portfolio management
- Architecture Overview --- Agent hierarchy and system design