Case Study 1: Building and Deploying a Live Trading Bot

Overview

This case study walks through the complete process of building a prediction market trading bot, from initial strategy design through paper trading validation to live deployment. We follow a trader named Alex who develops a calendar-spread strategy for political prediction markets, validates it systematically, and deploys it using the architecture from Chapter 19.

Part 1: Strategy Development

The Strategy Idea

Alex notices that prediction markets for recurring events (monthly economic reports, quarterly earnings, weekly political polls) exhibit predictable patterns in the days leading up to the event. Specifically, contract prices tend to drift toward extreme values (closer to 0 or 1) as the event approaches and uncertainty resolves. This creates opportunities for a mean-reversion strategy in the intermediate period.

The core thesis: In the 3-14 day window before an event, market prices overreact to minor news and tend to revert to a slowly-changing baseline. Inside 3 days, fundamental uncertainty dominates, and outside 14 days, the edge is too small.

Formalizing the Signal

Alex defines the signal as:

fair_value = exponential moving average of mid-price (span=48 hours)
signal = (fair_value - current_mid) / historical_std_of_deviations

Buy when signal > +1.5 (price significantly below fair value)
Sell when signal < -1.5 (price significantly above fair value)
Exit when signal crosses zero (price returns to fair value)

Backtesting Results

Alex backtests over 8 months of historical data across 45 recurring-event markets:

Metric Value
Total trades 312
Win rate 58.3%
Average win $22.40
Average loss $18.70
Profit factor 1.67
Max drawdown $420
Sharpe ratio 1.82
Average holding time 14 hours

These results look promising, but Alex knows backtesting assumptions are optimistic. The critical question: how much of this edge survives contact with reality?

Part 2: Building the Bot

Architecture Decisions

Alex chooses the architecture from Section 19.2:

"""
Alex's trading bot architecture decisions:

1. Data Feed: REST API polling every 30 seconds
   - WebSockets would be better for latency but the platform's
     WS API is unreliable
   - 30s is sufficient for a strategy with 14-hour average hold time

2. Signal Generator: Python implementation of the EMA-based
   mean reversion signal
   - Requires 48 hours of price history to compute the EMA
   - Signal threshold of 1.5 standard deviations

3. Risk Manager: Conservative limits for initial deployment
   - Max position: 100 contracts per market
   - Max daily loss: $200
   - Max 5 open positions simultaneously
   - Max portfolio exposure: $2,000

4. Order Executor: Limit orders only, passive pricing
   - Place buy limits 1 tick inside the best bid
   - Place sell limits 1 tick inside the best ask
   - Cancel unfilled orders after 10 minutes

5. Position Tracker: In-memory with periodic persistence
   - Save state to disk every 60 seconds
   - Reconcile with exchange every 5 minutes

6. Logger: Structured JSON logging to file + console
   - Rotate log files daily
   - Alert on email for CRITICAL events
"""

Key Implementation Details

Alex implements the signal generator:

import numpy as np
from collections import deque
from datetime import datetime, timedelta


class MeanReversionSignal:
    """
    Generates mean-reversion signals based on price deviation
    from exponential moving average.
    """

    def __init__(self, ema_span_hours: float = 48.0,
                 signal_threshold: float = 1.5,
                 min_history_hours: float = 72.0,
                 event_blackout_days: float = 3.0):
        self.ema_span_hours = ema_span_hours
        self.signal_threshold = signal_threshold
        self.min_history_hours = min_history_hours
        self.event_blackout_days = event_blackout_days

        # Per-market state
        self.price_history = {}  # market_id -> [(timestamp, price)]
        self.current_signals = {}  # market_id -> last signal value

    def __call__(self, update, history):
        """Called by SignalGenerator on each market update."""
        market_id = update.market_id

        # Calculate mid price
        if update.best_bid and update.best_ask:
            mid = (update.best_bid + update.best_ask) / 2
        elif update.last_price:
            mid = update.last_price
        else:
            return None

        # Maintain price history
        if market_id not in self.price_history:
            self.price_history[market_id] = deque(maxlen=10000)
        self.price_history[market_id].append(
            (update.timestamp, mid)
        )

        # Need minimum history
        prices = self.price_history[market_id]
        if len(prices) < 100:  # Need at least 100 data points
            return None

        first_timestamp = prices[0][0]
        hours_of_data = (
            update.timestamp - first_timestamp
        ).total_seconds() / 3600

        if hours_of_data < self.min_history_hours:
            return None

        # Calculate EMA
        price_array = np.array([p[1] for p in prices])
        alpha = 2.0 / (self.ema_span_hours * 120 + 1)
        # 120 data points per hour at 30s polling
        ema = self._calculate_ema(price_array, alpha)

        # Calculate signal
        deviation = mid - ema
        deviations = price_array - self._calculate_ema_array(
            price_array, alpha
        )
        std_dev = np.std(deviations)
        if std_dev < 0.001:
            return None

        signal_value = deviation / std_dev
        self.current_signals[market_id] = signal_value

        # Generate trading signals
        if signal_value > self.signal_threshold:
            # Price is above fair value -- sell signal
            return {
                "side": Side.SELL,
                "price": update.best_ask or mid,
                "confidence": min(abs(signal_value) / 3.0, 1.0),
                "metadata": {
                    "signal_value": signal_value,
                    "ema": ema,
                    "deviation": deviation,
                },
            }
        elif signal_value < -self.signal_threshold:
            # Price is below fair value -- buy signal
            return {
                "side": Side.BUY,
                "price": update.best_bid or mid,
                "confidence": min(abs(signal_value) / 3.0, 1.0),
                "metadata": {
                    "signal_value": signal_value,
                    "ema": ema,
                    "deviation": deviation,
                },
            }

        return None

    def _calculate_ema(self, prices, alpha):
        """Calculate the current EMA value."""
        ema = prices[0]
        for p in prices[1:]:
            ema = alpha * p + (1 - alpha) * ema
        return ema

    def _calculate_ema_array(self, prices, alpha):
        """Calculate EMA for every point in the array."""
        ema_arr = np.zeros_like(prices)
        ema_arr[0] = prices[0]
        for i in range(1, len(prices)):
            ema_arr[i] = alpha * prices[i] + (1 - alpha) * ema_arr[i-1]
        return ema_arr

Risk Configuration

Alex sets conservative risk limits for the initial deployment:

risk_limits = RiskLimits(
    max_position_size=100,        # Max 100 contracts per market
    max_position_value=500.0,     # Max $500 per position
    max_order_size=50,            # Max 50 contracts per order
    max_daily_loss=200.0,         # Stop trading after $200 loss
    max_portfolio_exposure=2000.0,  # Max $2000 total exposure
    max_category_exposure=800.0,  # Max $800 per category
    max_price_deviation=0.10,     # Max 10% from current market
    max_volume_fraction=0.03,     # Max 3% of daily volume
    min_spread=0.01,              # Don't trade if spread > 1 cent
)

Part 3: Paper Trading

Setup

Alex deploys the bot in paper trading mode for 21 days against live market data. The paper trading engine simulates: - 80% fill probability for limit orders - Random slippage with std dev of 0.2 cents - 20% chance of partial fills - 150ms average simulated latency

Paper Trading Results

After 21 days:

Metric Backtest (same period) Paper Trading
Total trades 68 54
Win rate 60.3% 57.4%
Average win $21.80 | $19.60
Average loss $17.90 | $18.40
Total P&L +$187 | +$98
Max drawdown $145 | $178
Fill rate 100% 78%
Average slippage 0 $0.003

Analysis

Alex observes several important differences:

  1. Fewer trades. The paper trader filled only 78% of orders, so 14 potential trades were missed. This accounts for most of the P&L difference.

  2. Lower win rate. 57.4% vs 60.3%. The missed fills tended to be on orders near the edge of filling -- exactly the ones that would have been marginal winners.

  3. Slightly worse average loss. $18.40 vs $17.90 in backtest. Slippage makes losing trades slightly worse.

  4. Higher drawdown. $178 vs $145. A few missed winning trades during a drawdown period extended the drawdown.

Adjustments Before Going Live

Based on paper trading results, Alex makes three changes:

  1. More aggressive pricing. Instead of placing limits 1 tick inside the best bid/ask, Alex moves to placing at the best bid/ask. This should improve fill rate at the cost of slightly higher slippage.

  2. Lower signal threshold. Reducing from 1.5 to 1.3 standard deviations. The paper trading showed that signals at 1.3-1.5 were profitable but often not filled. By trading at a slightly lower threshold, Alex expects more fills.

  3. Wider stop-loss. Increasing the exit threshold from 0 to -0.3 sigma. Some winning trades were exited prematurely at exact zero crossing.

Part 4: Small Live Deployment

Go/No-Go Decision

Alex reviews the go/no-go criteria:

  • [x] Paper trading ran for 21+ days without system crashes
  • [x] Paper trading P&L is positive (though lower than backtest, as expected)
  • [x] Fill rate explanation is satisfactory (passive pricing, not a system bug)
  • [x] All risk checks triggered correctly during testing
  • [x] Position reconciliation showed zero discrepancies
  • [x] Monitoring alerts fired correctly on simulated failures
  • [x] Runbook is written and tested
  • [x] Kill switch tested and accessible via mobile phone

Decision: GO for small live at 25% of target capital.

First Week of Live Trading

Alex starts with $500 of capital (25% of the target $2,000 allocation) and watches closely.

Day 1: Three signals, two orders placed, one filled. Bought 15 contracts at 0.43 in a market with a 0.42 bid / 0.44 ask. Fill price was 0.43 (at the ask). Current price by end of day: 0.44. Unrealized P&L: +$0.15. System ran without errors.

Day 2: One signal, one order placed, one filled. Sold the position from Day 1 at 0.455. Realized P&L: +$0.375. A new buy signal fired but was not filled (limit at 0.38, market moved up). No errors.

Day 3: Four signals, three orders placed, two filled. One position entered and exited the same day for a loss of $0.82. Another position opened (still held). One order expired unfilled after 10 minutes. System ran cleanly.

Day 4: Quiet day. No signals fired. Alex resisted the urge to tweak parameters. Checked the decision journal: "No action taken. System working as designed."

Day 5: The API returned a 500 error on one request. The retry handler retried after 2 seconds and succeeded. The circuit breaker did not trip (threshold = 5 failures). Alert email sent for the 500 error. Alex checked the platform status page -- they had reported a brief outage. System handled it correctly.

Days 6-7 (weekend): Lower volume. Two signals, one fill. Alex noticed the weekend markets are thinner and made a note to investigate whether the strategy performs differently on weekends.

First Week Summary

Metric Value
Trades 5
Win rate 60% (3/5)
Total P&L +$1.24
Max drawdown $0.82
Fill rate 62.5% (5/8 orders)
Average slippage $0.004
System uptime 100%
Manual interventions 0

Scaling Up

After two weeks of consistent operation with no system issues, Alex scales to 50% of target capital ($1,000) and widens position limits proportionally. After two more weeks, Alex scales to full target capital ($2,000).

Part 5: Ongoing Operations

Weekly Review Process

Every Sunday, Alex reviews:

  1. P&L by market and by day. Looking for patterns -- are certain markets or days consistently bad?

  2. Execution quality. Fill rate, slippage, and latency trends. Is execution getting better or worse?

  3. Risk check activity. How often were orders rejected? Were any rejections incorrect (would have been profitable trades)?

  4. System health. Any errors, warnings, or near-misses?

  5. Strategy signals. Are signal characteristics (frequency, distribution, accuracy) consistent with the backtest?

Month 1 Results

After the first full month of live trading:

Metric Paper Trading (21 days) Live Trading (30 days)
Total trades 54 73
Win rate 57.4% 56.2%
Average win $19.60 | $20.10
Average loss $18.40 | $19.30
Total P&L +$98 | +$104
Fill rate 78% 68%
Average slippage $0.003 | $0.005

Key observations:

  • Lower fill rate in live vs. paper. The paper trader was slightly optimistic. Alex adjusts the paper trading fill probability from 80% to 70% for future validation.
  • Higher slippage in live. Expected -- real market impact is not fully captured by simulation. Alex adjusts the slippage model to 0.5 cents std dev.
  • P&L is positive and within expected range. Not as high as the backtest, but the strategy appears to have genuine edge after execution costs.

Lessons Learned

Alex documents these lessons after the first month:

  1. Paper trading overstated fill rates by about 10%. Adjusted the simulation parameters.
  2. Weekend markets behave differently. Considering adding a "weekend mode" with tighter limits.
  3. The hardest part was not intervening. Several times Alex wanted to override the bot. The decision journal helped maintain discipline.
  4. Infrastructure issues were minor but real. Two API errors, one brief data delay. All handled by the retry and monitoring systems.
  5. The 48-hour EMA needs more data at strategy start. Added a warmup period where the bot collects data but does not trade for the first 72 hours after starting.

Code

The complete code for this case study is in code/case-study-code.py.