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