Skip to content

Repository files navigation

AdYield

An ad inventory and yield management platform, modelled on how a streaming publisher manages and monetises a finite pool of ad slots.

Ad inventory is perishable: a slot that airs unsold is worth nothing, exactly like an empty airline seat at pushback. That single property drives every interesting decision in this system — what to charge, what to hold back for direct sale, and what to release to the open market before it expires.

                       ┌──────────────┐
                       │   Demand     │  simulated advertisers
                       │  Generator   │  (package buys + spot buys)
                       └──────┬───────┘
                              │ HTTP
              ┌───────────────┼────────────────┐
              ▼               ▼                ▼
      ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
      │  Packaging   │ │   Booking   │ │   Pricing    │
      │  :8003       │ │   :8004     │ │   :8002      │
      └──────┬───────┘ └──────┬──────┘ └──────▲───────┘
             │                │               │
             │  reserve       │  reserve      │ quote
             ▼                ▼               │
          ┌────────────────────────┐          │
          │      Inventory  :8001  │──────────┘
          │  (sole writer of slots)│
          └───────────┬────────────┘
                      │  allocation
                      ▲
          ┌───────────┴────────────┐
          │   Yield Optimizer :8005│
          └────────────────────────┘

   Kafka topics: booking.created · inventory.updated · allocation.updated
   Store: DynamoDB (slots, rate_cards, packages, orders)

   Dashboard :5173  <-- WebSocket --  Gateway :8006  --> fans out to all
   (React + TypeScript)               (snapshot BFF)     five services / 3s

The core idea

Every ad slot is split into two buckets:

Bucket CPM Certainty Sold via
Guaranteed Lower (15% discount) High — a committed direct deal Packages, upfront
Programmatic Higher, demand-responsive Uncertain — depends on fill Open market, spot

The Yield Optimizer continuously decides where the boundary between them should sit. It compares expected value per impression on each side:

ev_guaranteed   = guaranteed_cpm   / 1000 × P(guaranteed side fills)
ev_programmatic = programmatic_cpm / 1000 × P(programmatic side fills)

and shifts inventory toward the better side. Two things scale how hard it shifts: the size of the EV gap (a near-tie barely moves the boundary) and urgency (the closer to air, the less time is left to correct a bad call, so late moves are larger). Inside the final six hours a direct deal is no longer realistically closeable, so all unsold guaranteed inventory is released to the open market rather than expiring worthless.

The policy is a pure function in adyield/optimizer/policy.py and is the most heavily tested part of the codebase.

Services

Service Port Responsibility
Inventory 8001 Owns the slot catalogue and availability. The only writer of slot records.
Pricing 8002 Rate cards plus a demand-responsive CPM fed by the Kafka event stream.
Packaging 8003 Bundles slots matching a targeting brief into a priced, sellable product.
Booking 8004 Places orders, reserves inventory, locks a price, publishes booking.created.
Optimizer 8005 Rebalances guaranteed vs programmatic on a timer and on events.
Gateway 8006 Backend-for-frontend: folds every service into one snapshot, streams it over a WebSocket.
Dashboard 5173 React + TypeScript live view of the whole platform.
Demand Simulated advertisers, so the system has live traffic to react to.

Running it

Prerequisites: Docker with the Compose plugin (docker compose version). That is all — every service, plus Kafka and DynamoDB, runs in a container. On macOS the daemon can come from Docker Desktop or Colima (brew install colima docker-compose && colima start).

docker compose up --build

That starts Kafka (KRaft, no ZooKeeper), DynamoDB Local, every service, seeds a 14-day schedule with rate cards, and begins generating advertiser demand. First run pulls images and builds, so give it a couple of minutes; Compose waits on each health check before starting dependents. Then open the dashboard at http://localhost:5173 — inventory utilisation, live CPMs, package sell-through and the optimizer's decisions, all streaming over a WebSocket.

To watch the yield engine from the terminal instead:

docker compose logs -f optimizer demand

To stop everything (and drop the in-memory data):

docker compose down

Useful endpoints once it is up:

curl localhost:8001/inventory/summary       # fill rate by segment
curl localhost:8002/pricing/demand/all      # live CPM drivers
curl localhost:8004/orders/stats/revenue    # guaranteed vs programmatic split
curl localhost:8005/optimizer/decisions     # what the optimizer just did and why
curl -X POST localhost:8005/optimizer/sweep # force a rebalance now

Interactive API docs are at /docs on every service.

Development

Backend — Python 3.12 specifically. On 3.13+ there are no prebuilt wheels for pydantic-core or confluent-kafka, and the install falls back to a Rust build that fails:

python3.12 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest
.venv/bin/ruff check adyield tests

Dashboard — Node 20+:

npm install --prefix dashboard
npm run --prefix dashboard typecheck
npm run --prefix dashboard build

The unit suite (91 tests) mocks DynamoDB with moto, so it needs no running infrastructure. The smoke test covers the wiring between services and needs a live stack:

docker compose run --rm --no-deps demand python -m adyield.demand.smoke

The dashboard is TypeScript strict mode with noUncheckedIndexedAccess and exactOptionalPropertyTypes on. CI runs lint, tests with an 80% coverage gate, the dashboard typecheck and build, both image builds, and the smoke test against a real stack.

To reseed with a different schedule length:

docker compose run --rm seed python -m adyield.demand.seed --days 30

Design decisions worth explaining

Bookings reserve synchronously; Kafka carries the consequences. The obvious event-driven design has Booking publish booking.created and Inventory decrement availability on consume. That oversells. Two advertisers can contend for the last impressions of a slot in the same millisecond, and an eventually-consistent decrement lets both win. So availability — a hard constraint — is enforced by a conditional write against the Inventory Service, and the event stream carries only what genuinely tolerates being a moment behind: the demand signal for pricing and the trigger for re-optimisation.

Oversell protection is optimistic, not lock-based. DynamoDB condition expressions cannot do arithmetic on attributes, so sold + n <= capacity is not directly expressible. reserve() reads current capacity, precomputes the bound, and conditions the write on both the sold ceiling and the capacity being unchanged. If the optimizer moved the boundary in between, the write fails and retries against fresh state. No locks, no oversell. See repository.reserve.

A multi-slot order that fails partway unwinds itself. Orders spanning several slots reserve them one at a time. If any leg fails, the already-taken legs are released before the order is rejected — a compensating transaction. The one path that can still leak inventory (a failed release) is logged loudly rather than silently retried, because it needs reconciliation.

Guaranteed pricing ignores short-term demand swings. Programmatic CPM moves with live fill rate; guaranteed does not. An advertiser signing a direct deal is buying price certainty, and repricing that under them on an hourly demand signal would defeat the point of the product.

The dashboard reads one aggregate, not five services. A browser polling Inventory, Pricing, Packaging, Booking and the Optimizer independently would tear — panels would disagree because each request lands at a different moment. The Gateway fans out once per refresh, folds the results into a single snapshot, and pushes that to every client, so what the dashboard shows is always internally consistent. It also degrades one panel at a time rather than blanking when a single upstream is slow.

Charts encode one measure per axis. Fill rate and revenue live on separate charts rather than sharing a plot with two y-scales; a dual axis aligns two arbitrary scales and implies a correlation the data does not contain. Colour carries a fixed meaning throughout — blue is always guaranteed, green always programmatic — and the palette was validated for colour-vision separation rather than picked by eye.

Segment fill rate is smoothed, then reconciled. Pricing derives fill rate from per-slot events, blended so one small slot filling up cannot reprice a whole segment. Because that smoothed view drifts, it is pulled back to ground truth from the Inventory Service on an interval.

Data model

AdSlot carries the invariant the whole system rests on:

impressions_reserved_guaranteed + impressions_available_programmatic
    == total_impressions

The Yield Optimizer moves that boundary; nothing else may. The boundary can never cut below what is already sold on either side — enforced in the model, in the policy, and again with a condition expression at the database.

Tables are keyed for the access patterns that matter: slots carry GSIs on show_id + air_date (schedule views) and segment + air_date (pricing and yield sweeps), so the hot read paths are queries rather than scans.

Stack

Python 3.12 · FastAPI · Pydantic v2 · DynamoDB · Kafka (confluent-kafka) · Docker Compose · GitHub Actions · pytest + moto · ruff

About

Ad inventory and yield management platform: dynamic pricing, rate cards, product packaging, and guaranteed vs programmatic yield optimisation over Kafka and DynamoDB.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages