Case Study 1: Building a Binary Options Prediction Market for Company Internal Forecasting

Background

Meridian Analytics, a mid-sized data science consultancy with 180 employees, struggled with project timeline forecasting. Senior management relied on optimistic estimates from project leads, resulting in missed deadlines on 60% of projects. The VP of Operations, Dana Reeves, had read about prediction markets as forecasting tools and proposed an internal platform where employees could bet virtual currency on project outcomes.

The requirements were: - Binary markets for each project milestone (e.g., "Will Feature X ship by March 15?") - LMSR pricing for guaranteed liquidity (the company could not rely on 180 employees to provide order-book liquidity) - JWT authentication integrated with the company's existing SSO via email domain verification - Simple REST API that a small React frontend could consume - Market resolution by designated project managers - Virtual currency with weekly top-ups (no real money involved)

The engineering team assigned two developers and gave them three weeks.

System Design

Architecture Decisions

The team chose FastAPI for the backend because of its automatic API documentation (critical for the frontend developer) and native Pydantic validation. They selected SQLite for the prototype phase, planning to migrate to PostgreSQL if the platform gained traction.

They decided on LMSR with $b = 50$ for all binary markets. This meant a maximum market-maker loss of $50 \cdot \ln(2) \approx \$34.66$ per market in virtual currency. With 20-30 active markets at any time, the total virtual subsidy was manageable.

┌─────────────────────────────────┐
│      React Frontend (SPA)       │
│   - Market dashboard            │
│   - Trading interface           │
│   - Position tracker            │
└──────────────┬──────────────────┘
               │  REST API (JSON)
┌──────────────▼──────────────────┐
│       FastAPI Backend            │
│  ┌─────────┐ ┌────────────────┐ │
│  │  Auth   │ │  Market & Trade│ │
│  │ (JWT)   │ │   Services     │ │
│  └────┬────┘ └───────┬────────┘ │
│       │              │          │
│  ┌────▼──────────────▼────────┐ │
│  │     LMSR Engine            │ │
│  │  (b=50, binary markets)    │ │
│  └────────────┬───────────────┘ │
│               │                  │
│  ┌────────────▼───────────────┐ │
│  │    SQLite Database         │ │
│  └────────────────────────────┘ │
└─────────────────────────────────┘

Data Model

The team extended the base models from Chapter 32 with company-specific fields:

"""Extended models for Meridian Analytics internal prediction market."""

from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, ForeignKey, Text
from sqlalchemy.orm import relationship
from database import Base


class Employee(Base):
    """Company employee with virtual currency balance.

    Attributes:
        employee_id: Internal employee ID from HR system.
        department: Department name for analytics.
        virtual_balance: Current virtual currency balance.
        weekly_allocation: Virtual currency added each Monday.
    """
    __tablename__ = "employees"

    id = Column(Integer, primary_key=True, index=True)
    employee_id = Column(String(20), unique=True, nullable=False)
    name = Column(String(100), nullable=False)
    email = Column(String(100), unique=True, nullable=False)
    department = Column(String(50), nullable=False)
    hashed_password = Column(String(255), nullable=False)
    virtual_balance = Column(Float, default=500.0)
    weekly_allocation = Column(Float, default=100.0)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)


class ProjectMarket(Base):
    """Prediction market tied to a specific project milestone.

    Attributes:
        project_name: Name of the project being forecasted.
        milestone: Specific milestone being predicted.
        resolver_id: Employee authorized to resolve the market.
        actual_outcome: Recorded after resolution for calibration analysis.
    """
    __tablename__ = "project_markets"

    id = Column(Integer, primary_key=True, index=True)
    project_name = Column(String(100), nullable=False)
    milestone = Column(String(200), nullable=False)
    description = Column(Text)
    resolver_id = Column(Integer, ForeignKey("employees.id"))
    liquidity_b = Column(Float, default=50.0)
    yes_shares = Column(Float, default=0.0)
    no_shares = Column(Float, default=0.0)
    status = Column(String(20), default="open")
    actual_outcome = Column(Boolean, nullable=True)
    closes_at = Column(DateTime, nullable=False)
    resolved_at = Column(DateTime, nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    resolver = relationship("Employee", foreign_keys=[resolver_id])

LMSR Configuration and Calibration

The team initially set $b = 50$ for all markets. After one week of operation with 12 active markets and about 40 participating employees, they observed that prices moved too quickly. A single employee buying 10 shares could shift a price by nearly 5 percentage points.

They analyzed the trading patterns:

"""Analysis of LMSR price sensitivity at different b values."""

import math


def price_impact(b: float, shares_bought: float, initial_price: float = 0.5) -> float:
    """Calculate the price impact of buying shares in a binary LMSR.

    Computes the new price of the "Yes" outcome after buying a given
    number of shares, starting from a specified initial price.

    Args:
        b: Liquidity parameter.
        shares_bought: Number of shares to buy.
        initial_price: Starting price of the "Yes" outcome.

    Returns:
        The new price after the purchase.
    """
    # Derive initial share difference from initial price
    # p = exp(q_yes/b) / (exp(q_yes/b) + exp(q_no/b))
    # If q_no = 0, then p = exp(q_yes/b) / (exp(q_yes/b) + 1)
    # Solving: q_yes = b * ln(p / (1 - p))
    q_yes = b * math.log(initial_price / (1 - initial_price))
    q_no = 0.0

    # After buying
    q_yes_new = q_yes + shares_bought
    new_price = math.exp(q_yes_new / b) / (
        math.exp(q_yes_new / b) + math.exp(q_no / b)
    )
    return new_price


# Compare b values
print("Price impact of buying 10 shares (starting at 0.50):")
print(f"  b=25:  {price_impact(25, 10):.4f} (moved {(price_impact(25, 10)-0.5)*100:.1f}pp)")
print(f"  b=50:  {price_impact(50, 10):.4f} (moved {(price_impact(50, 10)-0.5)*100:.1f}pp)")
print(f"  b=100: {price_impact(100, 10):.4f} (moved {(price_impact(100, 10)-0.5)*100:.1f}pp)")
print(f"  b=200: {price_impact(200, 10):.4f} (moved {(price_impact(200, 10)-0.5)*100:.1f}pp)")

# Output:
# Price impact of buying 10 shares (starting at 0.50):
#   b=25:  0.6225 (moved 12.2pp)
#   b=50:  0.5498 (moved 5.0pp)
#   b=100: 0.5250 (moved 2.5pp)
#   b=200: 0.5125 (moved 1.2pp)

Based on this analysis, the team increased $b$ to 100 for larger projects (those with more interested traders) and kept $b = 50$ for smaller projects. The maximum subsidy per market increased from $34.66 to $69.31 in virtual currency—still manageable.

Implementation

Core Trading Endpoint

The team built a simplified trading endpoint that handled only binary LMSR markets:

"""Simplified trading endpoint for binary LMSR markets."""

import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from database import get_db
from models import ProjectMarket, Employee
from auth import get_current_employee

router = APIRouter()


class TradeRequest(BaseModel):
    """Schema for trade requests in binary markets."""
    market_id: int
    side: str = Field(..., pattern=r"^(yes|no)$")
    shares: float = Field(..., gt=0, le=100)


class TradeResult(BaseModel):
    """Schema for trade execution results."""
    market_id: int
    side: str
    shares: float
    cost: float
    new_yes_price: float
    new_no_price: float
    balance_remaining: float


def lmsr_cost(yes_shares: float, no_shares: float, b: float) -> float:
    """Compute the LMSR cost function for a binary market.

    Args:
        yes_shares: Shares outstanding for "Yes."
        no_shares: Shares outstanding for "No."
        b: Liquidity parameter.

    Returns:
        The cost function value.
    """
    max_val = max(yes_shares / b, no_shares / b)
    return b * (max_val + math.log(
        math.exp(yes_shares / b - max_val) +
        math.exp(no_shares / b - max_val)
    ))


def lmsr_prices(yes_shares: float, no_shares: float, b: float) -> tuple[float, float]:
    """Compute LMSR prices for a binary market.

    Args:
        yes_shares: Shares outstanding for "Yes."
        no_shares: Shares outstanding for "No."
        b: Liquidity parameter.

    Returns:
        Tuple of (yes_price, no_price).
    """
    max_val = max(yes_shares / b, no_shares / b)
    exp_yes = math.exp(yes_shares / b - max_val)
    exp_no = math.exp(no_shares / b - max_val)
    total = exp_yes + exp_no
    return exp_yes / total, exp_no / total


@router.post("/trade", response_model=TradeResult)
async def execute_trade(
    trade: TradeRequest,
    employee: Employee = Depends(get_current_employee),
    db: Session = Depends(get_db),
):
    """Execute a trade against the LMSR in a binary market.

    Args:
        trade: The trade specification (market, side, shares).
        employee: The authenticated employee.
        db: Database session.

    Returns:
        Trade execution result with updated prices.

    Raises:
        HTTPException: If the market is not open or employee has
            insufficient balance.
    """
    market = db.query(ProjectMarket).filter(
        ProjectMarket.id == trade.market_id
    ).first()

    if not market:
        raise HTTPException(status_code=404, detail="Market not found")
    if market.status != "open":
        raise HTTPException(status_code=400, detail="Market is not open")
    if market.closes_at <= datetime.utcnow():
        raise HTTPException(status_code=400, detail="Market has closed")

    # Compute trade cost
    old_cost = lmsr_cost(market.yes_shares, market.no_shares, market.liquidity_b)

    if trade.side == "yes":
        new_yes = market.yes_shares + trade.shares
        new_no = market.no_shares
    else:
        new_yes = market.yes_shares
        new_no = market.no_shares + trade.shares

    new_cost = lmsr_cost(new_yes, new_no, market.liquidity_b)
    trade_cost = new_cost - old_cost

    # Check balance
    if employee.virtual_balance < trade_cost:
        raise HTTPException(
            status_code=400,
            detail=f"Insufficient balance. Need {trade_cost:.2f}, "
                   f"have {employee.virtual_balance:.2f}"
        )

    # Execute
    employee.virtual_balance -= trade_cost
    market.yes_shares = new_yes
    market.no_shares = new_no

    db.commit()

    yes_price, no_price = lmsr_prices(new_yes, new_no, market.liquidity_b)

    return TradeResult(
        market_id=market.id,
        side=trade.side,
        shares=trade.shares,
        cost=round(trade_cost, 4),
        new_yes_price=round(yes_price, 4),
        new_no_price=round(no_price, 4),
        balance_remaining=round(employee.virtual_balance, 4),
    )

Resolution Endpoint

The team added a calibration tracker to the resolution process. By comparing market prices at closing time to actual outcomes, they could measure the platform's forecasting accuracy:

"""Market resolution with calibration tracking."""

from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session

from database import get_db
from models import ProjectMarket, Employee, Position
from auth import get_current_employee

router = APIRouter()


class ResolutionRequest(BaseModel):
    """Schema for resolving a market."""
    outcome: bool  # True = "Yes" wins, False = "No" wins


@router.post("/markets/{market_id}/resolve")
async def resolve_market(
    market_id: int,
    resolution: ResolutionRequest,
    employee: Employee = Depends(get_current_employee),
    db: Session = Depends(get_db),
):
    """Resolve a market and distribute payouts.

    Only the designated resolver can resolve a market. The closing price
    is recorded for calibration analysis.

    Args:
        market_id: The ID of the market to resolve.
        resolution: The outcome (True for Yes, False for No).
        employee: The authenticated employee.
        db: Database session.

    Returns:
        Resolution summary with calibration data.
    """
    market = db.query(ProjectMarket).filter(
        ProjectMarket.id == market_id
    ).first()

    if not market:
        raise HTTPException(status_code=404, detail="Market not found")
    if market.resolver_id != employee.id:
        raise HTTPException(status_code=403, detail="Not authorized to resolve")
    if market.status == "resolved":
        raise HTTPException(status_code=400, detail="Already resolved")

    # Record the closing price for calibration
    import math
    max_val = max(market.yes_shares / market.liquidity_b,
                  market.no_shares / market.liquidity_b)
    exp_yes = math.exp(market.yes_shares / market.liquidity_b - max_val)
    exp_no = math.exp(market.no_shares / market.liquidity_b - max_val)
    closing_yes_price = exp_yes / (exp_yes + exp_no)

    # Resolve and pay out
    market.status = "resolved"
    market.actual_outcome = resolution.outcome
    market.resolved_at = datetime.utcnow()

    winning_side = "yes" if resolution.outcome else "no"
    total_payout = 0.0
    winners = 0

    positions = db.query(Position).filter(
        Position.market_id == market_id,
        Position.shares > 0,
    ).all()

    for pos in positions:
        if pos.side == winning_side:
            payout = pos.shares * 1.0
            holder = db.query(Employee).filter(
                Employee.id == pos.employee_id
            ).first()
            holder.virtual_balance += payout
            total_payout += payout
            winners += 1

    db.commit()

    return {
        "market_id": market_id,
        "milestone": market.milestone,
        "outcome": "Yes" if resolution.outcome else "No",
        "closing_yes_price": round(closing_yes_price, 4),
        "total_payout": round(total_payout, 2),
        "winners_count": winners,
        "calibration_note": (
            f"Market predicted {closing_yes_price:.0%} for Yes. "
            f"Actual outcome: {'Yes' if resolution.outcome else 'No'}."
        ),
    }

Results

Week 1-2: Adoption Phase

The platform launched with 8 markets across 3 active projects. Key metrics:

Metric Week 1 Week 2
Active traders 23 41
Total trades 87 214
Markets created 8 14
Avg trades per market 10.9 15.3

Employee engagement exceeded expectations. The gamification element (virtual leaderboard) drove participation even among non-technical staff.

Calibration Results (After 8 weeks)

After 8 weeks and 47 resolved markets, the team performed a calibration analysis. They grouped markets by their closing probability and compared against actual outcomes:

Predicted Probability Markets Actual "Yes" Rate Calibration Error
0.0 - 0.2 6 0.00 (0/6) 0.10
0.2 - 0.4 9 0.22 (2/9) 0.08
0.4 - 0.6 12 0.50 (6/12) 0.00
0.6 - 0.8 13 0.69 (9/13) 0.01
0.8 - 1.0 7 0.86 (6/7) 0.04

The overall Brier score was 0.168, significantly better than management's historical track record (estimated at 0.31 based on past project outcome data). The prediction market was roughly twice as accurate as traditional estimation.

Key Finding: Early Warning Signals

The most valuable outcome was not the final prediction but the early warning signals. In three cases, market prices dropped below 0.3 for milestone delivery more than two weeks before the deadline, giving management time to reallocate resources. Two of those three projects were ultimately brought back on track.

Lessons Learned

1. The Liquidity Parameter Matters Enormously

The initial $b = 50$ made prices too volatile. Single employees could move markets by 5+ percentage points, which discouraged casual participants who felt their small bets were meaningless next to a power trader's moves. Increasing to $b = 100$ for popular markets smoothed this out.

2. Resolution Authority Must Be Pre-Specified

In one case, a project manager resolved a market as "No" (milestone not met) while the engineering lead believed the milestone had been met. The ambiguity arose from an unclear milestone definition. The team added a "resolution criteria" field that required specific, measurable criteria before market creation.

3. Virtual Currency Design Affects Behavior

When everyone receives the same weekly allocation, employees with losing streaks accumulate less virtual capital and become less active. The team implemented a minimum balance floor (employees could never fall below 100 virtual credits) to keep everyone engaged.

4. Simplicity Wins

The team initially planned to support multi-outcome markets and conditional markets. They wisely deferred these features and launched with binary LMSR only. The simple "Yes/No" format was immediately understandable to non-technical employees and drove faster adoption.

5. Frontend Matters More Than Backend Sophistication

Employees spent 90% of their time looking at the market dashboard, not placing trades. Investing in a clean, real-time dashboard with price history charts drove more engagement than any backend optimization would have.

Technical Takeaways

  1. LMSR with $b = 100$ works well for internal binary markets with 40-80 active traders.
  2. SQLite is sufficient for internal tools with low concurrent write load (under 10 writes/second).
  3. FastAPI's auto-documentation saved development time because the frontend developer could explore endpoints interactively.
  4. Virtual currency top-ups prevent wealth concentration and keep participation broad.
  5. Calibration tracking provides the hard evidence needed to justify the platform's continued operation to management.