28 min read

> "The only free lunch in finance is diversification." -- Harry Markowitz

Chapter 17: Portfolio Construction and Risk Management

"The only free lunch in finance is diversification." -- Harry Markowitz

In previous chapters, we studied how to identify edge in individual prediction markets and how to size individual bets using the Kelly Criterion. But real prediction market trading rarely involves placing a single wager and waiting for its resolution. In practice, serious traders maintain portfolios of dozens or even hundreds of simultaneous positions across different markets, platforms, and event types. This chapter bridges the gap between single-trade analysis and the management of an entire prediction market portfolio.

We will cover the mathematics of portfolio diversification adapted to binary outcomes, extend the Kelly Criterion to simultaneous bets, build Monte Carlo simulations to stress-test our portfolios, and develop practical systems for managing drawdowns and bankroll. By the end, you will have the tools to construct, monitor, and protect a prediction market portfolio that balances growth with survival.


17.1 Why Portfolio Thinking Matters

The Limitations of Single-Trade Thinking

Imagine you have identified what you believe is a mispriced prediction market contract. The event has a true probability of 60%, but the market offers a price of $0.45 (implying 45%). You know from Chapter 14 that the Kelly Criterion suggests a bet size of approximately 27% of your bankroll on such an opportunity:

$$f^* = \frac{bp - q}{b} = \frac{\frac{0.55}{0.45} \times 0.60 - 0.40}{\frac{0.55}{0.45}} \approx 0.27$$

You place the bet and wait. The event either resolves Yes or No. If Yes, you earn a healthy profit. If No, you lose 27% of your bankroll in a single trade.

This is single-trade thinking: analyzing one opportunity in isolation, sizing it according to its individual merit, and living with the result. While not wrong in principle, this approach ignores critical realities:

  1. Variance is enormous on single binary outcomes. Unlike continuous-return assets where you might lose 2-5% on a bad day, a binary outcome can wipe out your entire stake instantly.

  2. Opportunities arise concurrently. While waiting for one election to resolve, you might see mispriced contracts on sporting events, economic indicators, and policy decisions.

  3. Correlations exist between markets. If you hold positions in five different U.S. Senate races, those outcomes are not independent. A national political wave can cause all five to move against you simultaneously.

  4. Bankroll is finite. If you Kelly-size each bet independently, you can easily end up wanting to wager more than 100% of your bankroll across simultaneous positions.

The Portfolio Approach

Portfolio thinking treats your collection of prediction market positions as a unified whole. Instead of asking "How much should I bet on this single market?" you ask "How should I allocate my total bankroll across all available opportunities, given their individual edges, their correlations, and my risk tolerance?"

This shift in perspective unlocks several advantages:

Reduced variance through diversification. If you hold 50 uncorrelated positions, each with positive expected value, the chance that a majority resolve against you is far smaller than the chance of losing any individual bet. The law of large numbers begins to work in your favor.

Better capital utilization. Instead of having most of your bankroll sitting idle while one large bet resolves, you can deploy capital across many opportunities simultaneously, increasing expected returns per unit of time.

Systematic risk management. With a portfolio view, you can measure and control aggregate risk metrics like maximum drawdown, Value at Risk, and risk of ruin -- concepts that are meaningless for a single binary bet.

Emotional stability. A diversified portfolio produces smoother returns over time, reducing the psychological toll of inevitable losses on individual positions.

Unique Challenges in Prediction Markets

Portfolio construction for prediction markets differs from traditional finance in several important ways:

Binary outcomes. Most prediction market contracts resolve to either $0 or $1, creating a discrete return distribution rather than the continuous returns seen in stock and bond portfolios. This fundamentally changes how we measure correlation and compute optimal allocations.

Resolution timing. Prediction market contracts have definite resolution dates, unlike equities that can be held indefinitely. This creates natural portfolio turnover and means positions "decay" as events resolve.

Illiquidity. Many prediction markets have limited depth, meaning large positions can move prices significantly. This constrains maximum position sizes and makes rebalancing costly.

Platform risk. Capital spread across multiple prediction market platforms faces counterparty risk. A platform failure can cause losses regardless of whether your predictions were correct.

Event clustering. Certain periods (elections, end of fiscal years, major sporting events) create clusters of simultaneous opportunities that may be highly correlated with each other.

These challenges make prediction market portfolio management both more difficult and more rewarding than simply applying textbook portfolio theory. We need adapted tools, and that is what this chapter provides.


17.2 Correlation in Prediction Markets

Why Correlation Matters

Correlation is the cornerstone of portfolio construction. Two prediction market positions that are perfectly positively correlated provide zero diversification benefit -- if one loses, the other loses too. Conversely, uncorrelated or negatively correlated positions can dramatically reduce portfolio variance while maintaining expected returns.

In traditional finance, correlation is measured between continuous return streams. In prediction markets, we need to think about correlation between binary events: the degree to which knowing the outcome of Event A tells us something about the outcome of Event B.

Measuring Correlation Between Binary Events

For two binary random variables $X$ and $Y$ with probabilities $p_X = P(X=1)$ and $p_Y = P(Y=1)$, the Pearson correlation coefficient is:

$$\rho_{XY} = \frac{P(X=1, Y=1) - p_X \cdot p_Y}{\sqrt{p_X(1-p_X) \cdot p_Y(1-p_Y)}}$$

This requires knowing the joint probability $P(X=1, Y=1)$. In practice, estimating joint probabilities is the hard part of correlation analysis in prediction markets.

The correlation is bounded by the marginal probabilities. For two binary events with probabilities $p_X$ and $p_Y$, the maximum possible correlation is:

$$\rho_{max} = \sqrt{\frac{\min(p_X, p_Y) \cdot (1 - \max(p_X, p_Y))}{\max(p_X, p_Y) \cdot (1 - \min(p_X, p_Y))}}$$

And the minimum possible correlation is:

$$\rho_{min} = -\sqrt{\frac{\min(p_X, 1-p_Y) \cdot \min(1-p_X, p_Y)}{\max(p_X, 1-p_Y) \cdot \max(1-p_X, p_Y)}}$$

These bounds are more restrictive than the usual $[-1, 1]$ range for continuous variables. For example, if $p_X = 0.9$ and $p_Y = 0.1$, the maximum positive correlation is only about 0.33, not 1.0.

Sources of Correlation in Prediction Markets

Political correlations. Senate races in the same election cycle share common factors: national mood, presidential coattails, economic conditions. A strong night for one party usually means a strong night across multiple races. In the 2020 U.S. elections, prediction markets learned this the hard way when correlated positions in multiple swing states all moved in the same direction.

Conditional markets. Some markets are explicitly linked. "Will X win the primary?" and "Will X win the general election?" are correlated because winning the general is conditional on winning the primary.

Macroeconomic linkages. "Will GDP growth exceed 3%?" and "Will unemployment fall below 4%?" are correlated through the underlying economic cycle.

Sporting correlations. In a tournament bracket, if Team A upsets Team B in Round 1, it changes the probabilities of all downstream matches involving those slots.

Weather and seasonal correlations. "Will it snow in New York on Christmas?" and "Will December temperatures be below average?" share a common weather driver.

Building a Correlation Matrix

For a portfolio of $n$ prediction market contracts, we need an $n \times n$ correlation matrix $\Sigma$. Each entry $\rho_{ij}$ captures the correlation between events $i$ and $j$.

In practice, you often cannot estimate correlations from historical data because prediction market events are typically one-time occurrences. Instead, you must rely on:

  1. Structural analysis. Identify common causal factors. Two Senate races in the same state share more common factors than races in different states.

  2. Conditional probability estimation. Estimate $P(Y=1 | X=1)$ and $P(Y=1 | X=0)$ through careful reasoning, then back out the joint probability and correlation.

  3. Market-implied correlations. If a platform offers both individual and joint contracts (e.g., "Party wins both Senate and Presidency"), you can back out the implied correlation.

  4. Historical analogues. While specific events do not repeat, you can study correlations between similar past event types (e.g., how correlated were Senate races in previous election cycles?).

  5. Expert judgment. Sometimes your best estimate comes from domain expertise about the causal structure linking events.

Python Correlation Estimator

import numpy as np
from itertools import combinations

def binary_correlation(p_x, p_y, p_joint):
    """
    Calculate Pearson correlation between two binary events.

    Parameters:
        p_x: P(X=1), marginal probability of event X
        p_y: P(Y=1), marginal probability of event Y
        p_joint: P(X=1, Y=1), joint probability of both events

    Returns:
        Pearson correlation coefficient
    """
    numerator = p_joint - p_x * p_y
    denominator = np.sqrt(p_x * (1 - p_x) * p_y * (1 - p_y))
    if denominator == 0:
        return 0.0
    return numerator / denominator

def correlation_bounds(p_x, p_y):
    """
    Compute the feasible range of correlations for two binary events.
    """
    # Joint probability bounds (Frechet bounds)
    p_joint_max = min(p_x, p_y)
    p_joint_min = max(0, p_x + p_y - 1)

    denom = np.sqrt(p_x * (1 - p_x) * p_y * (1 - p_y))
    if denom == 0:
        return 0.0, 0.0

    rho_max = (p_joint_max - p_x * p_y) / denom
    rho_min = (p_joint_min - p_x * p_y) / denom
    return rho_min, rho_max

def build_correlation_matrix(probabilities, conditional_probs=None):
    """
    Build a correlation matrix for a set of binary events.

    Parameters:
        probabilities: list of marginal probabilities for each event
        conditional_probs: dict mapping (i, j) -> P(j=1 | i=1)
                          If None, assume independence.

    Returns:
        Correlation matrix as numpy array
    """
    n = len(probabilities)
    corr_matrix = np.eye(n)

    for i, j in combinations(range(n), 2):
        p_i = probabilities[i]
        p_j = probabilities[j]

        if conditional_probs and (i, j) in conditional_probs:
            # P(j=1 | i=1) is given
            p_j_given_i = conditional_probs[(i, j)]
            p_joint = p_j_given_i * p_i
        elif conditional_probs and (j, i) in conditional_probs:
            p_i_given_j = conditional_probs[(j, i)]
            p_joint = p_i_given_j * p_j
        else:
            # Assume independence
            p_joint = p_i * p_j

        rho = binary_correlation(p_i, p_j, p_joint)
        corr_matrix[i, j] = rho
        corr_matrix[j, i] = rho

    return corr_matrix

# Example: Three Senate races with shared national factors
probs = [0.55, 0.60, 0.45]  # Marginal probabilities
# Conditional: if Dem wins race 0, 70% chance they win race 1
cond = {(0, 1): 0.70, (0, 2): 0.35, (1, 2): 0.35}

corr = build_correlation_matrix(probs, cond)
print("Correlation matrix:")
print(np.round(corr, 3))

This code lets you build the correlation structures that feed into portfolio optimization, which we turn to next.


17.3 Portfolio Kelly Criterion

From Single Kelly to Portfolio Kelly

In Chapter 14, we derived the Kelly Criterion for a single bet:

$$f^* = \frac{p \cdot b - q}{b}$$

where $p$ is the true probability, $q = 1 - p$, and $b$ is the odds received. This maximizes the expected logarithmic growth rate of your bankroll.

When you have multiple simultaneous betting opportunities, you cannot simply apply single-bet Kelly to each one independently. The reasons are:

  1. Budget constraint. The sum of all positions cannot exceed your total bankroll (and arguably should remain well below it).

  2. Correlation effects. Correlated bets compound risk. If two bets are perfectly correlated, holding both is equivalent to doubling down on one, which would over-bet relative to Kelly.

  3. Interaction effects. The optimal size for Bet A depends on how much you have allocated to Bet B, because your effective bankroll for any remaining bet shrinks as you allocate to other bets.

The Simultaneous Kelly Problem

For $n$ simultaneous binary bets, we want to find the vector of bet fractions $\mathbf{f} = (f_1, f_2, \ldots, f_n)$ that maximizes the expected log growth of the bankroll:

$$\max_{\mathbf{f}} \; E\left[\log\left(1 + \sum_{i=1}^{n} f_i \cdot R_i\right)\right]$$

where $R_i$ is the random return on bet $i$: - $R_i = b_i$ if event $i$ occurs (with probability $p_i$) - $R_i = -1$ if event $i$ does not occur (with probability $q_i = 1-p_i$)

Subject to: - $f_i \geq 0$ for all $i$ (no short selling, or allow it if the platform permits) - $\sum_{i=1}^{n} f_i \leq 1$ (budget constraint)

For $n$ binary events, there are $2^n$ possible joint outcomes. The expected log growth is:

$$G(\mathbf{f}) = \sum_{s=1}^{2^n} P(s) \cdot \log\left(1 + \sum_{i=1}^{n} f_i \cdot R_i(s)\right)$$

where $P(s)$ is the probability of joint outcome state $s$, and $R_i(s)$ is the return on bet $i$ in state $s$.

This is a concave optimization problem (the log of a linear function is concave), so any local maximum is the global maximum. Standard convex optimization solvers can handle it efficiently.

Accounting for Correlations

The joint probabilities $P(s)$ encode all correlation information. For two bets with marginal probabilities $p_1, p_2$ and correlation $\rho$:

$$P(X_1=1, X_2=1) = p_1 p_2 + \rho \sqrt{p_1 q_1 p_2 q_2}$$ $$P(X_1=1, X_2=0) = p_1 q_2 - \rho \sqrt{p_1 q_1 p_2 q_2}$$ $$P(X_1=0, X_2=1) = q_1 p_2 - \rho \sqrt{p_1 q_1 p_2 q_2}$$ $$P(X_1=0, X_2=0) = q_1 q_2 + \rho \sqrt{p_1 q_1 p_2 q_2}$$

For larger portfolios, generating the full $2^n$ joint distribution from a correlation matrix requires multivariate modeling. A common approach is to use a Gaussian copula:

  1. Generate $n$ correlated standard normal variables $Z_1, \ldots, Z_n$ using the correlation matrix.
  2. Convert each $Z_i$ to a binary outcome: $X_i = 1$ if $Z_i < \Phi^{-1}(p_i)$, else $X_i = 0$.

This preserves the marginal probabilities and approximately matches the specified correlations.

Numerical Optimization Approach

For portfolios beyond a few positions, the $2^n$ enumeration becomes impractical. Instead, we use sampling-based optimization:

  1. Generate $M$ samples from the joint distribution of outcomes (e.g., $M = 10{,}000$).
  2. For each candidate allocation $\mathbf{f}$, compute the sample average of $\log(1 + \sum f_i R_i)$.
  3. Use a numerical optimizer to find the $\mathbf{f}$ that maximizes this sample average.
import numpy as np
from scipy.optimize import minimize
from scipy.stats import norm

def generate_correlated_binary(probabilities, corr_matrix, n_samples=10000):
    """
    Generate correlated binary outcomes using a Gaussian copula.

    Parameters:
        probabilities: array of marginal probabilities
        corr_matrix: correlation matrix for the Gaussian copula
        n_samples: number of samples to generate

    Returns:
        Binary outcome matrix of shape (n_samples, n_events)
    """
    n_events = len(probabilities)
    # Generate correlated normals
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.randn(n_samples, n_events) @ L.T

    # Convert to binary using inverse CDF thresholds
    thresholds = norm.ppf(probabilities)
    outcomes = (Z < thresholds).astype(float)
    return outcomes

def portfolio_kelly(probabilities, market_prices, corr_matrix,
                    n_samples=50000, fractional_kelly=0.5):
    """
    Compute optimal portfolio Kelly fractions for correlated binary bets.

    Parameters:
        probabilities: array of true probabilities for each event
        market_prices: array of market prices (cost to buy Yes)
        corr_matrix: correlation matrix between events
        n_samples: Monte Carlo samples for optimization
        fractional_kelly: fraction of full Kelly to use (0.5 = half Kelly)

    Returns:
        Optimal bet fractions for each market
    """
    n = len(probabilities)

    # Generate correlated outcome scenarios
    outcomes = generate_correlated_binary(probabilities, corr_matrix, n_samples)

    # Returns matrix: profit per dollar wagered
    # If event occurs: win (1 - price) / price per unit bet fraction
    # If event doesn't: lose 1.0 per unit bet fraction
    # Bet fraction f_i means we spend f_i * bankroll to buy at price_i
    # We get f_i * (1/price_i) shares, each paying $1 if Yes
    # Profit if Yes: f_i * (1 - price_i) / price_i
    # Loss if No: -f_i

    returns = np.zeros_like(outcomes)
    for i in range(n):
        price = market_prices[i]
        win_return = (1 - price) / price  # Return per unit risked
        returns[:, i] = np.where(outcomes[:, i] == 1, win_return, -1.0)

    def neg_expected_log_growth(fractions):
        """Negative expected log growth (to minimize)."""
        portfolio_returns = 1.0 + returns @ fractions
        # Avoid log of non-positive numbers
        portfolio_returns = np.maximum(portfolio_returns, 1e-10)
        return -np.mean(np.log(portfolio_returns))

    # Constraints: fractions >= 0, sum of fractions <= 1
    bounds = [(0, 0.25) for _ in range(n)]  # Cap individual positions at 25%
    constraints = [{'type': 'ineq', 'fun': lambda f: 1.0 - np.sum(f)}]

    # Initial guess: equal weight scaled down
    x0 = np.full(n, min(0.02, 0.8 / n))

    result = minimize(neg_expected_log_growth, x0,
                      method='SLSQP', bounds=bounds,
                      constraints=constraints,
                      options={'maxiter': 1000, 'ftol': 1e-12})

    # Apply fractional Kelly
    optimal_fractions = result.x * fractional_kelly

    return optimal_fractions

# Example usage
probs = np.array([0.60, 0.55, 0.70, 0.45])
prices = np.array([0.45, 0.48, 0.60, 0.35])
corr = np.array([
    [1.0, 0.3, 0.1, 0.0],
    [0.3, 1.0, 0.1, 0.0],
    [0.1, 0.1, 1.0, 0.2],
    [0.0, 0.0, 0.2, 1.0]
])

fractions = portfolio_kelly(probs, prices, corr, fractional_kelly=0.5)
print("Optimal half-Kelly fractions:")
for i, f in enumerate(fractions):
    edge = probs[i] - prices[i]
    print(f"  Market {i+1}: {f:.4f} (edge: {edge:+.2f})")
print(f"  Total allocation: {sum(fractions):.4f}")

Key Insights from Portfolio Kelly

Several important patterns emerge from portfolio Kelly optimization:

Correlated bets get smaller allocations. When two bets share positive correlation, the optimizer reduces the size of both to avoid concentrated risk. If two bets with identical edge are perfectly correlated, the optimal allocation is roughly the same as for a single bet, split between them.

Uncorrelated bets are additive. With zero correlation, you can allocate close to the individual Kelly fraction for each bet, limited mainly by the budget constraint. This is the diversification benefit in action.

Negative correlation is a gift. If two positive-edge bets are negatively correlated, the optimizer may allocate more than the individual Kelly fraction to each, because their combined variance is lower than either alone.

Fractional Kelly is even more important in portfolios. Estimation errors in probabilities and correlations compound across a portfolio. Using half-Kelly or even quarter-Kelly provides a crucial buffer against model misspecification.


17.4 Position Sizing Rules

Overview of Position Sizing Approaches

While Kelly is theoretically optimal, practical position sizing requires additional rules and constraints. Here we survey the major approaches and their application to prediction market portfolios.

Fixed Fraction

The simplest approach: allocate the same fraction of your bankroll to every bet.

$$f_i = \frac{c}{n}$$

where $c$ is the total allocation (e.g., 0.5 for investing 50% of your bankroll) and $n$ is the number of positions.

Advantages: Simple, avoids overconcentration, no estimation required.

Disadvantages: Ignores edge size, treats all bets equally, wastes capital on marginal opportunities.

Kelly-Based Sizing

Use the full or fractional Kelly Criterion, adjusted for portfolio effects as described in Section 17.3.

Advantages: Theoretically optimal for long-run growth, allocates more to higher-edge bets.

Disadvantages: Requires accurate probability estimates, can produce aggressive allocations, sensitive to estimation errors.

Volatility Targeting

Set position sizes so that each position contributes equal volatility (risk) to the portfolio. For a binary bet at price $p$ with position size $f$:

$$\text{Volatility contribution} = f \cdot \sqrt{p(1-p)} \cdot \frac{1}{p}$$

Scale each $f_i$ so that volatility contributions are equalized:

$$f_i \propto \frac{1}{\sqrt{p_i(1-p_i)} / p_i}$$

Advantages: Balances risk across positions, prevents extreme-odds bets from dominating.

Disadvantages: Ignores edge, may undersize high-edge opportunities.

Maximum Position Limits

Regardless of the sizing method used, impose hard caps on individual and aggregate positions:

  • Individual position cap: No single bet exceeds X% of bankroll (common: 2-5%).
  • Aggregate allocation cap: Total invested capital does not exceed Y% of bankroll (common: 50-80%).
  • Correlated group cap: Total allocation to a correlated group does not exceed Z% (common: 10-20%).
  • Platform cap: No more than W% of capital on any single platform (common: 25-40%).

Practical Position Sizing Framework

In practice, the best approach combines multiple methods:

  1. Start with Kelly-based sizing for each opportunity.
  2. Apply fractional Kelly (typically 25-50% of full Kelly).
  3. Impose individual position caps.
  4. Check correlated group limits and scale down proportionally if exceeded.
  5. Verify the aggregate allocation stays within budget.
  6. Apply platform diversification constraints.
import numpy as np

class PositionSizer:
    """
    Practical position sizing for prediction market portfolios.
    Combines Kelly-based sizing with risk limits.
    """

    def __init__(self, bankroll, max_individual=0.05, max_aggregate=0.70,
                 max_correlated_group=0.15, max_platform=0.35,
                 kelly_fraction=0.5):
        self.bankroll = bankroll
        self.max_individual = max_individual
        self.max_aggregate = max_aggregate
        self.max_correlated_group = max_correlated_group
        self.max_platform = max_platform
        self.kelly_fraction = kelly_fraction

    def single_kelly(self, true_prob, market_price):
        """Compute single-bet Kelly fraction."""
        if true_prob <= market_price:
            return 0.0  # No edge
        b = (1 - market_price) / market_price
        q = 1 - true_prob
        kelly = (b * true_prob - q) / b
        return max(0, kelly * self.kelly_fraction)

    def size_portfolio(self, opportunities):
        """
        Size a portfolio of opportunities.

        Parameters:
            opportunities: list of dicts with keys:
                'name', 'true_prob', 'market_price',
                'correlation_group', 'platform'

        Returns:
            List of dicts with added 'fraction' and 'dollar_amount' keys
        """
        # Step 1: Compute Kelly-based sizes
        for opp in opportunities:
            opp['raw_kelly'] = self.single_kelly(
                opp['true_prob'], opp['market_price']
            )

        # Step 2: Apply individual caps
        for opp in opportunities:
            opp['fraction'] = min(opp['raw_kelly'], self.max_individual)

        # Step 3: Apply correlated group caps
        groups = {}
        for opp in opportunities:
            g = opp.get('correlation_group', 'default')
            groups.setdefault(g, []).append(opp)

        for group_name, group_opps in groups.items():
            total = sum(o['fraction'] for o in group_opps)
            if total > self.max_correlated_group:
                scale = self.max_correlated_group / total
                for o in group_opps:
                    o['fraction'] *= scale

        # Step 4: Apply platform caps
        platforms = {}
        for opp in opportunities:
            p = opp.get('platform', 'default')
            platforms.setdefault(p, []).append(opp)

        for plat_name, plat_opps in platforms.items():
            total = sum(o['fraction'] for o in plat_opps)
            if total > self.max_platform:
                scale = self.max_platform / total
                for o in plat_opps:
                    o['fraction'] *= scale

        # Step 5: Apply aggregate cap
        total = sum(o['fraction'] for o in opportunities)
        if total > self.max_aggregate:
            scale = self.max_aggregate / total
            for o in opportunities:
                o['fraction'] *= scale

        # Step 6: Calculate dollar amounts
        for opp in opportunities:
            opp['dollar_amount'] = opp['fraction'] * self.bankroll

        return opportunities

    def summary(self, opportunities):
        """Print a portfolio sizing summary."""
        total_frac = sum(o['fraction'] for o in opportunities)
        total_dollars = sum(o['dollar_amount'] for o in opportunities)

        print(f"{'Market':<25} {'Edge':>6} {'Kelly':>7} {'Final':>7} {'Amount':>10}")
        print("-" * 60)
        for o in opportunities:
            edge = o['true_prob'] - o['market_price']
            print(f"{o['name']:<25} {edge:>+.3f} {o['raw_kelly']:>7.4f} "
                  f"{o['fraction']:>7.4f} ${o['dollar_amount']:>9.2f}")
        print("-" * 60)
        print(f"{'Total':<25} {'':>6} {'':>7} {total_frac:>7.4f} ${total_dollars:>9.2f}")
        print(f"Bankroll utilization: {total_frac:.1%}")

# Example
sizer = PositionSizer(bankroll=10000, kelly_fraction=0.5)
opps = [
    {'name': 'Senate Race A', 'true_prob': 0.60, 'market_price': 0.50,
     'correlation_group': 'elections', 'platform': 'Polymarket'},
    {'name': 'Senate Race B', 'true_prob': 0.55, 'market_price': 0.48,
     'correlation_group': 'elections', 'platform': 'Polymarket'},
    {'name': 'GDP > 3%', 'true_prob': 0.40, 'market_price': 0.30,
     'correlation_group': 'economy', 'platform': 'Kalshi'},
    {'name': 'Oscar Best Picture', 'true_prob': 0.35, 'market_price': 0.25,
     'correlation_group': 'entertainment', 'platform': 'Polymarket'},
    {'name': 'Fed Rate Cut', 'true_prob': 0.65, 'market_price': 0.55,
     'correlation_group': 'economy', 'platform': 'Kalshi'},
]

sized = sizer.size_portfolio(opps)
sizer.summary(sized)

17.5 Diversification Strategies

The Goal of Diversification

Diversification in prediction markets aims to reduce portfolio variance without sacrificing expected returns. Since binary outcomes have inherently high variance (each position either pays off fully or goes to zero), diversification is even more important here than in traditional investing.

The variance of a portfolio of $n$ equally-weighted uncorrelated binary bets, each with probability $p$ of success, is:

$$\text{Var}(R_p) = \frac{p(1-p)}{n} \cdot \left(\frac{1-p}{p}\right)^2$$

This decreases as $1/n$, the classic diversification effect. But if bets are correlated with average pairwise correlation $\bar{\rho}$, the portfolio variance converges to:

$$\text{Var}(R_p) \to \bar{\rho} \cdot p(1-p) \cdot \left(\frac{1-p}{p}\right)^2$$

as $n \to \infty$. This irreducible variance due to correlation is systematic risk that cannot be diversified away.

Diversification Across Event Types

The most powerful diversification comes from holding positions in fundamentally unrelated event types:

Event Type Examples Typical Correlations
Politics Elections, legislation, appointments High within type
Economics GDP, inflation, employment Moderate within type
Sports Game outcomes, championships Low to moderate
Entertainment Awards, box office Very low
Science/Tech Discoveries, launches Very low
Weather Temperature records, storms Moderate (seasonal)
Geopolitics Conflicts, treaties Low to moderate

A portfolio mixing positions across all these categories achieves far better diversification than one concentrated in a single domain.

Diversification Across Time Horizons

Holding positions with different resolution dates smooths returns over time:

  • Short-term (days to weeks): Higher turnover, faster feedback, smaller edge per trade.
  • Medium-term (weeks to months): Moderate turnover, reasonable edge opportunities.
  • Long-term (months to years): Lower turnover, potentially larger edges due to illiquidity premium, but capital is tied up longer.

A healthy portfolio typically has a mix: 30-40% in short-term positions for income and feedback, 40-50% in medium-term for core returns, and 10-20% in long-term for strategic alpha.

Diversification Across Platforms

Spreading capital across multiple prediction market platforms provides two benefits:

  1. Access to more markets. Different platforms specialize in different event types.
  2. Reduced platform risk. If one platform faces regulatory, technical, or solvency issues, only a fraction of your capital is affected.

However, multi-platform diversification comes with costs: multiple accounts to manage, different interfaces, currency conversion, and potentially different fee structures.

Diversification Across Strategies

Use different analytical approaches simultaneously:

  • Fundamental analysis: Deep research into event probabilities.
  • Market-making: Providing liquidity and capturing bid-ask spreads.
  • Arbitrage: Exploiting price differences across platforms.
  • Statistical models: Quantitative models for forecasting.
  • Sentiment/contrarian: Betting against market overreactions.

Measuring Diversification Benefit

The diversification ratio measures how much benefit you get from combining positions:

$$\text{DR} = \frac{\sum_{i=1}^{n} w_i \sigma_i}{\sigma_p}$$

where $w_i$ are portfolio weights, $\sigma_i$ are individual position volatilities, and $\sigma_p$ is portfolio volatility. A DR of 1.0 means no diversification benefit (perfect correlation). Higher values indicate greater benefit.

import numpy as np

class DiversificationAnalyzer:
    """Analyze diversification benefits in a prediction market portfolio."""

    def __init__(self, positions, corr_matrix):
        """
        Parameters:
            positions: list of dicts with 'name', 'weight', 'prob', 'price'
            corr_matrix: correlation matrix between events
        """
        self.positions = positions
        self.corr_matrix = np.array(corr_matrix)
        self.n = len(positions)

        self.weights = np.array([p['weight'] for p in positions])
        self.probs = np.array([p['prob'] for p in positions])
        self.prices = np.array([p['price'] for p in positions])

        # Individual volatilities (of returns)
        self.vols = np.sqrt(self.probs * (1 - self.probs)) / self.prices

    def portfolio_volatility(self):
        """Calculate portfolio volatility considering correlations."""
        # Covariance matrix
        cov = np.outer(self.vols, self.vols) * self.corr_matrix
        port_var = self.weights @ cov @ self.weights
        return np.sqrt(port_var)

    def diversification_ratio(self):
        """Calculate the diversification ratio."""
        weighted_vol_sum = np.sum(self.weights * self.vols)
        port_vol = self.portfolio_volatility()
        if port_vol == 0:
            return float('inf')
        return weighted_vol_sum / port_vol

    def marginal_contribution_to_risk(self):
        """
        Calculate each position's marginal contribution to portfolio risk.
        Useful for identifying which positions add the most risk.
        """
        cov = np.outer(self.vols, self.vols) * self.corr_matrix
        port_vol = self.portfolio_volatility()

        # Marginal contribution = (Cov @ w) * w / port_vol
        mcr = (cov @ self.weights) * self.weights / port_vol
        return mcr

    def concentration_metrics(self):
        """Calculate portfolio concentration metrics."""
        # Herfindahl-Hirschman Index
        hhi = np.sum(self.weights ** 2)

        # Effective number of bets
        eff_n = 1.0 / hhi if hhi > 0 else self.n

        # Maximum weight
        max_w = np.max(self.weights)

        return {
            'hhi': hhi,
            'effective_n_bets': eff_n,
            'max_weight': max_w,
            'top_5_concentration': np.sum(np.sort(self.weights)[-5:])
        }

    def category_concentration(self):
        """Analyze diversification across event categories."""
        categories = {}
        for i, pos in enumerate(self.positions):
            cat = pos.get('category', 'Unknown')
            categories.setdefault(cat, 0)
            categories[cat] += self.weights[i]
        return categories

    def report(self):
        """Print a comprehensive diversification report."""
        print("=" * 60)
        print("DIVERSIFICATION ANALYSIS REPORT")
        print("=" * 60)

        print(f"\nNumber of positions: {self.n}")
        print(f"Portfolio volatility: {self.portfolio_volatility():.4f}")
        print(f"Diversification ratio: {self.diversification_ratio():.2f}")

        conc = self.concentration_metrics()
        print(f"\nConcentration Metrics:")
        print(f"  HHI: {conc['hhi']:.4f}")
        print(f"  Effective # of bets: {conc['effective_n_bets']:.1f}")
        print(f"  Max single weight: {conc['max_weight']:.2%}")
        print(f"  Top 5 concentration: {conc['top_5_concentration']:.2%}")

        cats = self.category_concentration()
        print(f"\nCategory Allocation:")
        for cat, weight in sorted(cats.items(), key=lambda x: -x[1]):
            print(f"  {cat}: {weight:.2%}")

        mcr = self.marginal_contribution_to_risk()
        print(f"\nTop Risk Contributors:")
        sorted_idx = np.argsort(mcr)[::-1]
        for idx in sorted_idx[:5]:
            print(f"  {self.positions[idx]['name']}: {mcr[idx]:.4f} "
                  f"({mcr[idx]/np.sum(mcr):.1%} of total risk)")

17.6 Risk Metrics for Prediction Portfolios

Why Standard Risk Metrics Need Adaptation

Traditional risk metrics like Value at Risk (VaR) and Sharpe Ratio were designed for portfolios with approximately continuous return distributions. Prediction markets produce discrete, binary outcomes, which requires adaptation of these metrics.

Maximum Drawdown

Maximum drawdown (MDD) measures the largest peak-to-trough decline in portfolio value:

$$\text{MDD} = \max_{t} \left( \frac{\max_{s \leq t} V_s - V_t}{\max_{s \leq t} V_s} \right)$$

where $V_t$ is the portfolio value at time $t$.

In prediction markets, drawdowns can occur both from positions resolving against you and from mark-to-market price movements before resolution. Both types matter:

  • Realized drawdowns come from resolved bets that lost.
  • Unrealized drawdowns come from price movements on open positions.

Value at Risk (VaR) for Binary Outcomes

For a portfolio of binary bets, VaR at confidence level $\alpha$ is the loss that will not be exceeded with probability $\alpha$. Because outcomes are discrete, VaR is computed by:

  1. Enumerating or simulating all possible outcome combinations.
  2. Computing the portfolio return for each combination.
  3. Finding the $\alpha$-percentile of the loss distribution.

$$\text{VaR}_\alpha = -\text{Quantile}_{1-\alpha}(R_p)$$

For example, 95% VaR tells you the loss that is exceeded only 5% of the time.

Expected Shortfall (Conditional VaR)

Expected Shortfall (ES) is the average loss in the worst $(1-\alpha)$% of scenarios:

$$\text{ES}_\alpha = -E[R_p \mid R_p \leq -\text{VaR}_\alpha]$$

ES is considered a better risk measure than VaR because it accounts for the severity of tail losses, not just their threshold.

Risk of Ruin

Risk of ruin is the probability that your bankroll drops below a critical level (e.g., 10% of starting capital) at any point during a sequence of bets. For a single bet repeated many times:

$$P(\text{ruin}) = \left(\frac{q}{p}\right)^{B/u}$$

where $B$ is the starting bankroll, $u$ is the bet size, $p$ is the win probability, and $q = 1-p$, assuming $p > q$.

For a portfolio of heterogeneous bets, risk of ruin is best estimated through Monte Carlo simulation (Section 17.7).

Sharpe Ratio for Prediction Markets

The Sharpe Ratio measures risk-adjusted returns:

$$\text{SR} = \frac{E[R_p] - R_f}{\sigma_p}$$

For prediction markets, the risk-free rate $R_f$ is the return on uninvested capital (often near zero for crypto-based platforms, or the savings rate for fiat platforms). The expected return $E[R_p]$ and standard deviation $\sigma_p$ should be computed from the binary outcome distribution, not from price time series.

An adjusted Sharpe ratio for prediction markets might use the semi-deviation (downside risk) instead of standard deviation:

$$\text{Sortino} = \frac{E[R_p] - R_f}{\sigma_{\text{down}}}$$

where $\sigma_{\text{down}} = \sqrt{E[\min(R_p - R_f, 0)^2]}$.

Python Risk Metrics Suite

import numpy as np
from scipy.stats import norm

class RiskMetrics:
    """Comprehensive risk metrics for prediction market portfolios."""

    def __init__(self, portfolio_returns):
        """
        Parameters:
            portfolio_returns: array of simulated or historical portfolio returns
        """
        self.returns = np.array(portfolio_returns)
        self.n = len(self.returns)

    def expected_return(self):
        """Expected portfolio return."""
        return np.mean(self.returns)

    def volatility(self):
        """Portfolio return standard deviation."""
        return np.std(self.returns, ddof=1)

    def var(self, alpha=0.95):
        """Value at Risk at confidence level alpha."""
        return -np.percentile(self.returns, (1 - alpha) * 100)

    def expected_shortfall(self, alpha=0.95):
        """Expected Shortfall (CVaR) at confidence level alpha."""
        var = self.var(alpha)
        tail_losses = self.returns[self.returns <= -var]
        if len(tail_losses) == 0:
            return var
        return -np.mean(tail_losses)

    def maximum_drawdown(self):
        """
        Maximum drawdown from a sequence of returns.
        Assumes returns are sequential (not i.i.d. samples).
        """
        cumulative = np.cumprod(1 + self.returns)
        running_max = np.maximum.accumulate(cumulative)
        drawdowns = (running_max - cumulative) / running_max
        return np.max(drawdowns)

    def sharpe_ratio(self, risk_free_rate=0.0):
        """Sharpe ratio."""
        excess = self.expected_return() - risk_free_rate
        vol = self.volatility()
        if vol == 0:
            return float('inf') if excess > 0 else 0.0
        return excess / vol

    def sortino_ratio(self, risk_free_rate=0.0):
        """Sortino ratio (using downside deviation)."""
        excess = self.expected_return() - risk_free_rate
        downside = self.returns[self.returns < risk_free_rate]
        if len(downside) == 0:
            return float('inf') if excess > 0 else 0.0
        downside_dev = np.sqrt(np.mean((downside - risk_free_rate) ** 2))
        if downside_dev == 0:
            return float('inf') if excess > 0 else 0.0
        return excess / downside_dev

    def calmar_ratio(self):
        """Calmar ratio: annualized return / max drawdown."""
        mdd = self.maximum_drawdown()
        if mdd == 0:
            return float('inf')
        return self.expected_return() / mdd

    def risk_of_ruin(self, ruin_threshold=0.1):
        """
        Estimate probability that bankroll drops below ruin_threshold
        of starting value. Uses the simulated return sequence.
        """
        n_paths = 1000
        path_length = len(self.returns)
        ruin_count = 0

        for _ in range(n_paths):
            # Random permutation of returns (bootstrap)
            shuffled = np.random.choice(self.returns, size=path_length,
                                        replace=True)
            cumulative = np.cumprod(1 + shuffled)
            if np.any(cumulative < ruin_threshold):
                ruin_count += 1

        return ruin_count / n_paths

    def win_rate(self):
        """Fraction of positive returns."""
        return np.mean(self.returns > 0)

    def profit_factor(self):
        """Gross profits / gross losses."""
        gains = self.returns[self.returns > 0].sum()
        losses = abs(self.returns[self.returns < 0].sum())
        if losses == 0:
            return float('inf')
        return gains / losses

    def report(self):
        """Print comprehensive risk report."""
        print("=" * 50)
        print("RISK METRICS REPORT")
        print("=" * 50)
        print(f"Number of observations: {self.n}")
        print(f"\nReturn Metrics:")
        print(f"  Expected return:   {self.expected_return():>10.4f}")
        print(f"  Volatility:        {self.volatility():>10.4f}")
        print(f"  Win rate:          {self.win_rate():>10.2%}")
        print(f"  Profit factor:     {self.profit_factor():>10.2f}")
        print(f"\nRisk Metrics:")
        print(f"  VaR (95%):         {self.var(0.95):>10.4f}")
        print(f"  VaR (99%):         {self.var(0.99):>10.4f}")
        print(f"  Expected Shortfall:{self.expected_shortfall(0.95):>10.4f}")
        print(f"  Max Drawdown:      {self.maximum_drawdown():>10.4f}")
        print(f"\nRisk-Adjusted Metrics:")
        print(f"  Sharpe Ratio:      {self.sharpe_ratio():>10.4f}")
        print(f"  Sortino Ratio:     {self.sortino_ratio():>10.4f}")
        print(f"  Calmar Ratio:      {self.calmar_ratio():>10.4f}")
        print(f"\nTail Risk:")
        print(f"  Risk of Ruin (10%):{self.risk_of_ruin(0.10):>10.4f}")

17.7 Monte Carlo Simulation

Why Monte Carlo?

Analytical formulas for portfolio risk become intractable when dealing with many correlated binary outcomes. Monte Carlo simulation lets us estimate any portfolio statistic by:

  1. Generating thousands of possible future scenarios.
  2. Computing the portfolio outcome under each scenario.
  3. Analyzing the distribution of outcomes.

For prediction market portfolios, Monte Carlo is particularly powerful because:

  • Binary outcomes do not follow normal distributions.
  • Correlations between events create complex joint distributions.
  • Path-dependent statistics like maximum drawdown require sequential simulation.
  • Non-linear position sizing rules (caps, Kelly adjustments) make analytical solutions impossible.

Simulating Correlated Binary Events

The Gaussian copula approach from Section 17.3 extends naturally to Monte Carlo simulation:

import numpy as np
from scipy.stats import norm
import matplotlib.pyplot as plt

class MonteCarloEngine:
    """
    Monte Carlo simulation engine for prediction market portfolios.
    Handles correlated binary outcomes.
    """

    def __init__(self, seed=42):
        self.rng = np.random.default_rng(seed)

    def simulate_outcomes(self, probabilities, corr_matrix, n_simulations):
        """
        Simulate correlated binary outcomes.

        Parameters:
            probabilities: array of true probabilities
            corr_matrix: correlation matrix
            n_simulations: number of scenarios to generate

        Returns:
            Binary outcome matrix (n_simulations x n_events)
        """
        n_events = len(probabilities)

        # Cholesky decomposition for correlated normals
        L = np.linalg.cholesky(corr_matrix)
        Z = self.rng.standard_normal((n_simulations, n_events)) @ L.T

        # Convert to binary
        thresholds = norm.ppf(probabilities)
        outcomes = (Z < thresholds).astype(float)

        return outcomes

    def simulate_portfolio(self, positions, corr_matrix, n_simulations=10000):
        """
        Simulate portfolio returns across many scenarios.

        Parameters:
            positions: list of dicts with:
                'prob': true probability
                'price': market price
                'weight': fraction of bankroll allocated
            corr_matrix: correlation matrix
            n_simulations: number of scenarios

        Returns:
            Array of portfolio returns (one per simulation)
        """
        probs = np.array([p['prob'] for p in positions])
        prices = np.array([p['price'] for p in positions])
        weights = np.array([p['weight'] for p in positions])

        outcomes = self.simulate_outcomes(probs, corr_matrix, n_simulations)

        # Compute returns per position per scenario
        # Win: gain (1 - price) for each unit of price paid
        # Loss: lose the price paid
        returns_if_win = (1 - prices) / prices
        returns_if_lose = -1.0

        position_returns = np.where(
            outcomes == 1,
            returns_if_win,
            returns_if_lose
        )

        # Portfolio return = weighted sum of position returns
        portfolio_returns = position_returns @ weights

        return portfolio_returns

    def simulate_sequential(self, positions, corr_matrix,
                            n_simulations=5000, n_rounds=20):
        """
        Simulate sequential portfolio outcomes over multiple rounds
        of betting. Useful for drawdown and path analysis.

        Each round, all positions resolve simultaneously,
        bankroll is updated, and positions are re-entered.

        Returns:
            Bankroll paths: array of shape (n_simulations, n_rounds + 1)
        """
        probs = np.array([p['prob'] for p in positions])
        prices = np.array([p['price'] for p in positions])
        weights = np.array([p['weight'] for p in positions])

        paths = np.ones((n_simulations, n_rounds + 1))

        for t in range(n_rounds):
            outcomes = self.simulate_outcomes(probs, corr_matrix, n_simulations)

            returns_if_win = (1 - prices) / prices
            position_returns = np.where(outcomes == 1, returns_if_win, -1.0)
            portfolio_returns = position_returns @ weights

            paths[:, t + 1] = paths[:, t] * (1 + portfolio_returns)

        return paths

    def analyze_results(self, portfolio_returns):
        """Compute summary statistics from simulation results."""
        results = {
            'mean_return': np.mean(portfolio_returns),
            'median_return': np.median(portfolio_returns),
            'std_return': np.std(portfolio_returns),
            'min_return': np.min(portfolio_returns),
            'max_return': np.max(portfolio_returns),
            'prob_positive': np.mean(portfolio_returns > 0),
            'prob_loss_gt_10pct': np.mean(portfolio_returns < -0.10),
            'prob_loss_gt_25pct': np.mean(portfolio_returns < -0.25),
            'var_95': -np.percentile(portfolio_returns, 5),
            'var_99': -np.percentile(portfolio_returns, 1),
            'cvar_95': -np.mean(portfolio_returns[
                portfolio_returns <= np.percentile(portfolio_returns, 5)
            ]),
            'skewness': float(
                np.mean(((portfolio_returns - np.mean(portfolio_returns))
                         / np.std(portfolio_returns)) ** 3)
            ),
        }
        return results

    def analyze_paths(self, paths):
        """Analyze sequential simulation paths."""
        # Maximum drawdown for each path
        max_drawdowns = np.zeros(paths.shape[0])
        for i in range(paths.shape[0]):
            running_max = np.maximum.accumulate(paths[i])
            drawdowns = (running_max - paths[i]) / running_max
            max_drawdowns[i] = np.max(drawdowns)

        # Final bankroll statistics
        final = paths[:, -1]

        results = {
            'median_final_bankroll': np.median(final),
            'mean_final_bankroll': np.mean(final),
            'prob_profit': np.mean(final > 1.0),
            'prob_double': np.mean(final > 2.0),
            'prob_ruin_50pct': np.mean(np.min(paths, axis=1) < 0.50),
            'prob_ruin_25pct': np.mean(np.min(paths, axis=1) < 0.25),
            'prob_ruin_10pct': np.mean(np.min(paths, axis=1) < 0.10),
            'mean_max_drawdown': np.mean(max_drawdowns),
            'median_max_drawdown': np.median(max_drawdowns),
            'p95_max_drawdown': np.percentile(max_drawdowns, 95),
            'worst_max_drawdown': np.max(max_drawdowns),
        }
        return results

    def print_report(self, positions, corr_matrix, n_simulations=10000):
        """Generate and print a full simulation report."""
        returns = self.simulate_portfolio(
            positions, corr_matrix, n_simulations
        )
        stats = self.analyze_results(returns)

        print("=" * 55)
        print("MONTE CARLO SIMULATION REPORT")
        print(f"({n_simulations:,} simulations)")
        print("=" * 55)
        print(f"\nReturn Distribution:")
        print(f"  Mean return:         {stats['mean_return']:>+10.4f}")
        print(f"  Median return:       {stats['median_return']:>+10.4f}")
        print(f"  Std deviation:       {stats['std_return']:>10.4f}")
        print(f"  Min return:          {stats['min_return']:>+10.4f}")
        print(f"  Max return:          {stats['max_return']:>+10.4f}")
        print(f"  Skewness:            {stats['skewness']:>10.4f}")
        print(f"\nProbabilities:")
        print(f"  P(profit):           {stats['prob_positive']:>10.2%}")
        print(f"  P(loss > 10%):       {stats['prob_loss_gt_10pct']:>10.2%}")
        print(f"  P(loss > 25%):       {stats['prob_loss_gt_25pct']:>10.2%}")
        print(f"\nRisk Metrics:")
        print(f"  VaR (95%):           {stats['var_95']:>10.4f}")
        print(f"  VaR (99%):           {stats['var_99']:>10.4f}")
        print(f"  CVaR (95%):          {stats['cvar_95']:>10.4f}")

        return stats

Confidence Intervals on Returns

Monte Carlo simulation naturally produces confidence intervals. For an expected portfolio return estimate $\hat{\mu}$ from $N$ simulations with sample standard deviation $s$:

$$\hat{\mu} \pm z_{\alpha/2} \cdot \frac{s}{\sqrt{N}}$$

With $N = 10{,}000$ simulations, the standard error of the mean is $s / 100$, giving precise estimates. However, tail statistics like 99% VaR require more simulations for stability. A general guideline:

Statistic Minimum Simulations
Mean return 1,000
Standard deviation 5,000
95% VaR 10,000
99% VaR 50,000
Risk of ruin 100,000

Interpreting Simulation Results

When reviewing Monte Carlo output, focus on these questions:

  1. Is the expected return positive? If not, your portfolio lacks edge.
  2. What is the probability of loss? Even positive-edge portfolios can lose. Ensure this probability is acceptable.
  3. How bad can it get? Look at VaR and CVaR to understand tail risk.
  4. How long to recover? Use sequential simulation to estimate recovery time from drawdowns.
  5. Is the result sensitive to correlation assumptions? Run simulations with higher correlations to see how results degrade.

17.8 Drawdown Management

What Is a Drawdown?

A drawdown is any decline from a peak portfolio value. If your portfolio was worth $12,000 at its peak and is currently worth $10,000, you are in a 16.7% drawdown:

$$\text{Drawdown} = \frac{12{,}000 - 10{,}000}{12{,}000} = 16.7\%$$

Drawdowns are inevitable, even for skilled traders with genuine edge. The question is not whether you will experience drawdowns, but how deep they will be, how long they will last, and whether you can survive them.

Defining Acceptable Drawdowns

Before you begin trading, establish drawdown limits:

Drawdown Level Classification Action
0-10% Normal No action needed
10-20% Elevated Review positions, reduce sizing
20-30% Serious Halve position sizes, close marginal bets
30-40% Critical Reduce to 25% of normal sizing
40%+ Emergency Close all positions, reassess strategy

These thresholds should be set in advance when you are thinking rationally, not in the heat of a losing streak.

Circuit Breakers

Implement automated rules that trigger when drawdowns reach specified levels:

class DrawdownMonitor:
    """
    Monitor portfolio drawdowns and enforce risk limits.
    """

    def __init__(self, initial_bankroll,
                 warning_level=0.10,
                 reduce_level=0.15,
                 halt_level=0.25,
                 emergency_level=0.40):
        self.initial_bankroll = initial_bankroll
        self.peak_bankroll = initial_bankroll
        self.current_bankroll = initial_bankroll
        self.history = [initial_bankroll]

        self.warning_level = warning_level
        self.reduce_level = reduce_level
        self.halt_level = halt_level
        self.emergency_level = emergency_level

        self.position_scale = 1.0  # Current position sizing multiplier
        self.is_halted = False
        self.drawdown_log = []

    def update(self, new_bankroll):
        """Update bankroll and check drawdown levels."""
        self.current_bankroll = new_bankroll
        self.history.append(new_bankroll)

        # Update peak
        if new_bankroll > self.peak_bankroll:
            self.peak_bankroll = new_bankroll
            # Reset scaling when we make new highs
            self.position_scale = 1.0
            self.is_halted = False

        # Calculate drawdown
        dd = self.current_drawdown()

        # Determine action
        status = self._evaluate_drawdown(dd)
        self.drawdown_log.append({
            'bankroll': new_bankroll,
            'peak': self.peak_bankroll,
            'drawdown': dd,
            'status': status,
            'position_scale': self.position_scale
        })

        return status

    def current_drawdown(self):
        """Calculate current drawdown from peak."""
        if self.peak_bankroll == 0:
            return 0.0
        return (self.peak_bankroll - self.current_bankroll) / self.peak_bankroll

    def _evaluate_drawdown(self, dd):
        """Evaluate drawdown and set position scaling."""
        if dd >= self.emergency_level:
            self.position_scale = 0.0
            self.is_halted = True
            return 'EMERGENCY - ALL POSITIONS CLOSED'
        elif dd >= self.halt_level:
            self.position_scale = 0.25
            return 'CRITICAL - Sizing at 25%'
        elif dd >= self.reduce_level:
            self.position_scale = 0.50
            return 'SERIOUS - Sizing at 50%'
        elif dd >= self.warning_level:
            self.position_scale = 0.75
            return 'WARNING - Sizing at 75%'
        else:
            self.position_scale = 1.0
            return 'NORMAL'

    def adjusted_position_size(self, base_size):
        """Return position size adjusted for current drawdown state."""
        return base_size * self.position_scale

    def max_drawdown(self):
        """Calculate maximum drawdown from history."""
        peak = self.history[0]
        max_dd = 0
        for val in self.history:
            if val > peak:
                peak = val
            dd = (peak - val) / peak
            if dd > max_dd:
                max_dd = dd
        return max_dd

    def recovery_estimate(self, expected_return_per_round=0.02):
        """
        Estimate rounds needed to recover from current drawdown.

        Assumes expected return per round with reduced sizing.
        """
        if self.current_drawdown() == 0:
            return 0

        target = self.peak_bankroll
        current = self.current_bankroll
        scale = self.position_scale

        # Adjusted expected return
        adj_return = expected_return_per_round * scale

        if adj_return <= 0:
            return float('inf')

        # Rounds to recover: target/current = (1 + adj_return)^n
        # n = log(target/current) / log(1 + adj_return)
        n = np.log(target / current) / np.log(1 + adj_return)
        return int(np.ceil(n))

    def report(self):
        """Print drawdown status report."""
        dd = self.current_drawdown()
        max_dd = self.max_drawdown()

        print("=" * 50)
        print("DRAWDOWN MONITOR REPORT")
        print("=" * 50)
        print(f"  Initial bankroll:  ${self.initial_bankroll:>10,.2f}")
        print(f"  Peak bankroll:     ${self.peak_bankroll:>10,.2f}")
        print(f"  Current bankroll:  ${self.current_bankroll:>10,.2f}")
        print(f"  Current drawdown:  {dd:>10.2%}")
        print(f"  Maximum drawdown:  {max_dd:>10.2%}")
        print(f"  Position scaling:  {self.position_scale:>10.0%}")
        print(f"  Status: {self._evaluate_drawdown(dd)}")

        recovery = self.recovery_estimate()
        if recovery < float('inf'):
            print(f"  Est. rounds to recover: {recovery}")
        else:
            print(f"  Recovery: HALTED (no new positions)")

Reducing Position Sizes During Drawdowns

The logic behind reducing sizes during drawdowns is twofold:

  1. Mathematical. If your edge estimates were wrong (which the drawdown might be evidence of), smaller sizing protects against further losses.

  2. Psychological. Large drawdowns create emotional pressure that leads to poor decisions. Smaller positions reduce the emotional stakes, helping you think clearly.

A graduated approach (as in the code above) is better than a binary on/off switch. Gradually reducing sizing as drawdowns deepen preserves some upside while limiting downside.

Psychological Aspects of Drawdowns

Even with perfect mathematical understanding, drawdowns are psychologically painful. Common reactions include:

  • Revenge trading: Increasing bet sizes to "win it back quickly." This is the opposite of what you should do.
  • Strategy abandonment: Discarding a sound strategy because of a losing streak that is well within expected variance.
  • Paralysis: Becoming afraid to place any bets, even clearly profitable ones.
  • Denial: Ignoring the drawdown and continuing to trade at full size.

Countermeasures:

  1. Pre-commit to rules. Write down your drawdown management rules before you start trading.
  2. Automate where possible. Use code (like the DrawdownMonitor) to enforce rules mechanically.
  3. Keep a trading journal. Record your reasoning for each trade to review during drawdowns. Are you making sound decisions, or has emotion taken over?
  4. Take breaks. After a significant drawdown, step away for a day or two. Markets will still be there when you return.
  5. Review base rates. Remind yourself what normal drawdowns look like for your strategy. A 15% drawdown might feel catastrophic but be perfectly normal for a strategy with 30% expected annual return.

Recovery Time Estimation

The time to recover from a drawdown depends on your edge and position sizing. If your expected return per round is $\mu$ and you are in a drawdown of fraction $d$:

$$\text{Recovery rounds} = \frac{\log(1/(1-d))}{\log(1 + \mu)}$$

For example, recovering from a 20% drawdown with a 2% expected return per round requires approximately:

$$\frac{\log(1/0.8)}{\log(1.02)} \approx \frac{0.223}{0.020} \approx 11 \text{ rounds}$$

But this is with full-size positions. If you have scaled down to 50% during the drawdown, the effective return is only 1%, doubling the recovery time to about 22 rounds.


17.9 Bankroll Management

The Foundation of Survival

Bankroll management is the practice of structuring your capital to ensure you can survive adverse outcomes and continue trading. It sits at the intersection of mathematics and discipline.

Separating Trading Capital from Reserves

Never put your entire available capital into prediction market trading. Structure your capital into tiers:

Tier Purpose Percentage Access
Trading Capital Active positions 40-60% of total Deployed on platforms
Reserve Capital Replenish losses 20-30% of total Accessible but not deployed
Emergency Fund Personal safety net 10-20% of total Separate account
Risk Capital Only amount you can lose Sum of above Total prediction market capital

The Reserve Capital tier is particularly important. It provides a buffer that lets you: - Replenish trading capital after drawdowns. - Add capital when new opportunities arise. - Avoid desperate trading to "save" a depleted bankroll.

Growth vs Safety Tradeoff

Aggressive bankroll management (deploying more capital, using full Kelly) maximizes expected growth but increases ruin risk. Conservative management (deploying less, using fractional Kelly) sacrifices some growth for safety.

The right balance depends on:

  1. Replaceability of capital. Can you earn more trading capital from other income? If so, you can afford to be more aggressive.
  2. Time horizon. Longer time horizons favor more conservative approaches, as they increase the probability of encountering extreme drawdowns.
  3. Skill certainty. How confident are you in your edge? Less certainty should mean more conservative bankroll management.
  4. Opportunity cost. What else could you do with the capital? Higher opportunity costs favor more conservative deployment.

When to Add Capital

Adding capital to your trading bankroll should be systematic, not emotional:

Good reasons to add capital: - Your strategy has been profitable over a meaningful sample size. - New opportunities have arisen that you cannot fund from current capital. - You have reached a new all-time high and want to scale up proportionally.

Bad reasons to add capital: - You want to recover from a drawdown faster. - You feel pressure to "make up for lost time." - A single trade looks "too good to miss."

When to Withdraw Capital

Withdrawing profits regularly serves several purposes:

  1. Reduces risk. Capital withdrawn cannot be lost.
  2. Provides feedback. If you are withdrawing profits regularly, your strategy is working.
  3. Maintains discipline. It prevents the temptation to over-trade with a growing bankroll.

A simple rule: withdraw 50% of profits above your target bankroll each month. If your target bankroll is $10,000 and you end the month with $11,200, withdraw $600 (50% of the $1,200 profit).

Python Bankroll Tracker

import numpy as np
from datetime import datetime, timedelta

class BankrollTracker:
    """Track bankroll, deposits, withdrawals, and performance over time."""

    def __init__(self, initial_deposit, target_bankroll=None):
        self.transactions = [
            {'date': datetime.now(), 'type': 'deposit',
             'amount': initial_deposit, 'note': 'Initial deposit'}
        ]
        self.daily_values = [
            {'date': datetime.now(), 'value': initial_deposit}
        ]
        self.target_bankroll = target_bankroll or initial_deposit
        self.total_deposited = initial_deposit
        self.total_withdrawn = 0

    def deposit(self, amount, note=""):
        """Record a deposit."""
        self.transactions.append({
            'date': datetime.now(), 'type': 'deposit',
            'amount': amount, 'note': note
        })
        self.total_deposited += amount

    def withdraw(self, amount, note=""):
        """Record a withdrawal."""
        self.transactions.append({
            'date': datetime.now(), 'type': 'withdrawal',
            'amount': amount, 'note': note
        })
        self.total_withdrawn += amount

    def record_value(self, value, date=None):
        """Record the current portfolio value."""
        self.daily_values.append({
            'date': date or datetime.now(),
            'value': value
        })

    def suggested_withdrawal(self, current_value, profit_withdrawal_pct=0.5):
        """
        Suggest a withdrawal amount based on profits above target.
        """
        profit_above_target = current_value - self.target_bankroll
        if profit_above_target <= 0:
            return 0
        return profit_above_target * profit_withdrawal_pct

    def net_profit(self, current_value):
        """Calculate net profit accounting for deposits and withdrawals."""
        return current_value + self.total_withdrawn - self.total_deposited

    def roi(self, current_value):
        """Return on investment."""
        if self.total_deposited == 0:
            return 0
        return self.net_profit(current_value) / self.total_deposited

    def report(self, current_value):
        """Print bankroll report."""
        print("=" * 50)
        print("BANKROLL TRACKER REPORT")
        print("=" * 50)
        print(f"  Total deposited:    ${self.total_deposited:>10,.2f}")
        print(f"  Total withdrawn:    ${self.total_withdrawn:>10,.2f}")
        print(f"  Current value:      ${current_value:>10,.2f}")
        print(f"  Net profit:         ${self.net_profit(current_value):>+10,.2f}")
        print(f"  ROI:                {self.roi(current_value):>+10.2%}")
        print(f"  Target bankroll:    ${self.target_bankroll:>10,.2f}")

        suggested = self.suggested_withdrawal(current_value)
        if suggested > 0:
            print(f"\n  Suggested withdrawal: ${suggested:>10,.2f}")

        if len(self.daily_values) > 1:
            values = [d['value'] for d in self.daily_values]
            peak = max(values)
            dd = (peak - current_value) / peak if peak > 0 else 0
            print(f"\n  Peak value:         ${peak:>10,.2f}")
            print(f"  Current drawdown:   {dd:>10.2%}")

17.10 Rebalancing and Portfolio Maintenance

When to Rebalance

Prediction market portfolios require rebalancing for several reasons:

  1. Position resolution. As events resolve, capital is freed up (or lost) and must be redeployed.
  2. Price movements. As market prices change, the edge on each position changes, potentially requiring size adjustments.
  3. New information. Your probability estimates may update, changing the optimal allocation.
  4. New opportunities. New markets open that may be more attractive than current holdings.

Unlike traditional portfolios where rebalancing is typically calendar-based (monthly, quarterly), prediction market rebalancing should be event-driven:

  • Rebalance when a position resolves. Redeploy the freed capital.
  • Rebalance when edge changes significantly. If a position's edge drops below your minimum threshold, close it.
  • Rebalance when new high-edge opportunities appear. Reallocate from lower-edge to higher-edge positions.

Cost of Rebalancing

Rebalancing in prediction markets incurs several costs:

  • Bid-ask spread. Closing and opening positions crosses the spread, which can be wide in illiquid markets.
  • Price impact. Large orders can move prices against you.
  • Transaction fees. Some platforms charge per-trade fees.
  • Opportunity cost of attention. Time spent rebalancing could be spent on research.

As a rule of thumb, only rebalance when the expected improvement exceeds twice the transaction cost. If closing and re-opening a position costs 2% in spreads and fees, only do it if the expected improvement is at least 4%.

New Opportunity Integration

When a new opportunity appears, evaluate it in the context of your existing portfolio:

  1. Does it add diversification? A new opportunity in an uncorrelated event type is more valuable than one correlated with existing positions.
  2. Does it have sufficient edge? The minimum edge should cover transaction costs plus a hurdle rate for your time.
  3. Where does the capital come from? You must either deploy reserve capital or close an existing position. Only close an existing position if the new opportunity has clearly higher risk-adjusted edge.

Portfolio Decay Management

Prediction market portfolios naturally "decay" as events resolve. Without active management:

  • Resolved positions free capital that earns no return.
  • The portfolio becomes increasingly concentrated in the few remaining unresolved positions.
  • Time-sensitive opportunities may be missed.

To combat decay:

  1. Maintain a pipeline of opportunities. Always have a watchlist of potential new positions.
  2. Set capital deployment targets. Aim to keep 50-70% of your trading capital actively deployed.
  3. Review weekly. Assess whether your current deployment matches your target allocation.

17.11 Stress Testing

Why Stress Test?

Normal Monte Carlo simulation reveals the range of likely outcomes. Stress testing reveals what happens under extreme conditions that may not be captured by your base assumptions. In prediction markets, stress scenarios include:

  • Correlation spikes. During a crisis or major event, normally uncorrelated markets become correlated.
  • Probability estimation errors. What if your edge is half of what you think?
  • Liquidity evaporation. What if you cannot close positions when you need to?
  • Platform failure. What if a major platform goes down?
  • Systematic model failure. What if your entire forecasting approach has a blind spot?

Scenario Analysis

Define specific scenarios and compute their impact on your portfolio:

import numpy as np

class StressTester:
    """Stress testing framework for prediction market portfolios."""

    def __init__(self, positions, base_corr_matrix):
        """
        Parameters:
            positions: list of dicts with 'name', 'prob', 'price', 'weight',
                      'category'
            base_corr_matrix: normal-conditions correlation matrix
        """
        self.positions = positions
        self.base_corr = np.array(base_corr_matrix)
        self.n = len(positions)

    def scenario_edge_reduction(self, edge_multiplier=0.5):
        """
        Scenario: Your edges are smaller than estimated.
        Recompute expected returns with reduced edge.
        """
        results = []
        for pos in self.positions:
            original_edge = pos['prob'] - pos['price']
            reduced_prob = pos['price'] + original_edge * edge_multiplier
            reduced_prob = max(0.01, min(0.99, reduced_prob))

            expected_return = (
                reduced_prob * (1 - pos['price']) / pos['price']
                + (1 - reduced_prob) * (-1)
            ) * pos['weight']

            results.append({
                'name': pos['name'],
                'original_edge': original_edge,
                'reduced_edge': reduced_prob - pos['price'],
                'expected_return': expected_return
            })

        total = sum(r['expected_return'] for r in results)
        return results, total

    def scenario_correlation_spike(self, spike_level=0.7):
        """
        Scenario: All correlations jump to a high level.
        Common during crises when "everything is correlated."
        """
        stressed_corr = np.full_like(self.base_corr, spike_level)
        np.fill_diagonal(stressed_corr, 1.0)
        return stressed_corr

    def scenario_category_wipeout(self, category):
        """
        Scenario: All positions in a given category lose.
        Returns the portfolio impact.
        """
        total_loss = 0
        affected = []
        for pos in self.positions:
            if pos.get('category') == category:
                loss = pos['weight']  # Lose entire position
                total_loss += loss
                affected.append(pos['name'])

        return {
            'category': category,
            'affected_positions': affected,
            'total_loss': total_loss,
            'remaining_bankroll_pct': 1 - total_loss
        }

    def scenario_platform_failure(self, platform):
        """
        Scenario: A platform fails, losing all capital on it.
        """
        total_loss = 0
        affected = []
        for pos in self.positions:
            if pos.get('platform') == platform:
                total_loss += pos['weight']
                affected.append(pos['name'])

        return {
            'platform': platform,
            'affected_positions': affected,
            'total_loss': total_loss,
            'remaining_bankroll_pct': 1 - total_loss
        }

    def worst_case_analysis(self, n_worst=5):
        """
        Find the N positions whose simultaneous loss would be worst.
        """
        weights = np.array([p['weight'] for p in self.positions])
        sorted_idx = np.argsort(weights)[::-1]

        worst_positions = []
        total_loss = 0
        for i in sorted_idx[:n_worst]:
            worst_positions.append(self.positions[i]['name'])
            total_loss += weights[i]

        return {
            'positions': worst_positions,
            'total_loss': total_loss,
            'remaining_bankroll_pct': 1 - total_loss
        }

    def run_all_scenarios(self):
        """Run all stress scenarios and print results."""
        print("=" * 60)
        print("STRESS TEST REPORT")
        print("=" * 60)

        # Edge reduction
        print("\n--- Scenario: 50% Edge Reduction ---")
        results, total = self.scenario_edge_reduction(0.5)
        print(f"  Expected portfolio return: {total:+.4f}")
        still_positive = sum(1 for r in results if r['expected_return'] > 0)
        print(f"  Positions still profitable: {still_positive}/{len(results)}")

        # Edge elimination
        print("\n--- Scenario: Zero Edge (no skill) ---")
        results, total = self.scenario_edge_reduction(0.0)
        print(f"  Expected portfolio return: {total:+.4f}")
        print(f"  (This represents the cost of trading without edge)")

        # Category wipeouts
        categories = set(p.get('category', 'Unknown') for p in self.positions)
        print("\n--- Scenario: Category Wipeout ---")
        for cat in categories:
            result = self.scenario_category_wipeout(cat)
            if result['total_loss'] > 0:
                print(f"  {cat}: Loss = {result['total_loss']:.2%}, "
                      f"Remaining = {result['remaining_bankroll_pct']:.2%}")

        # Platform failure
        platforms = set(p.get('platform', 'Unknown') for p in self.positions)
        print("\n--- Scenario: Platform Failure ---")
        for plat in platforms:
            result = self.scenario_platform_failure(plat)
            if result['total_loss'] > 0:
                print(f"  {plat}: Loss = {result['total_loss']:.2%}, "
                      f"Remaining = {result['remaining_bankroll_pct']:.2%}")

        # Worst case
        print("\n--- Scenario: 5 Largest Positions All Lose ---")
        worst = self.worst_case_analysis(5)
        print(f"  Total loss: {worst['total_loss']:.2%}")
        print(f"  Remaining: {worst['remaining_bankroll_pct']:.2%}")
        for name in worst['positions']:
            print(f"    - {name}")

Historical Stress Scenarios

Study past events that caused prediction market turmoil:

  • 2016 U.S. Presidential Election: Markets priced Clinton at 85-90% on election day. Traders holding large Clinton positions experienced catastrophic losses, especially those concentrated in correlated political markets.
  • COVID-19 onset (March 2020): Disrupted nearly every type of prediction market -- sports were cancelled, economic indicators plunged, political calendars shifted.
  • Brexit referendum (2016): Markets priced Remain at ~85% on the day of the vote. The shock affected markets well beyond UK politics.

These events share a common pattern: correlations spiked dramatically, turning "diversified" portfolios into concentrated bets on a single narrative.

Tail Risk Assessment

For each stress scenario, assess:

  1. Probability. How likely is this scenario? (Be honest -- investors systematically underestimate tail risk.)
  2. Impact. What percentage of your bankroll would you lose?
  3. Recovery. How long would recovery take?
  4. Survivability. Would you still be solvent? Could you continue trading?

If any plausible scenario threatens your ability to continue trading, you are taking too much risk.


17.12 Chapter Summary

This chapter has covered the essential tools and concepts for managing a prediction market portfolio as a unified whole, rather than a collection of isolated bets.

Key concepts reviewed:

  1. Portfolio thinking shifts the focus from individual bets to aggregate risk and return. It unlocks diversification benefits and enables systematic risk management.

  2. Correlation between prediction market events is the critical input to portfolio construction. Binary events have bounded correlations that depend on marginal probabilities, and correlations must often be estimated from structural analysis rather than historical data.

  3. Portfolio Kelly extends the Kelly Criterion to simultaneous bets, accounting for correlations through numerical optimization. Fractional Kelly (25-50% of full) provides crucial protection against estimation errors.

  4. Position sizing combines Kelly-based calculations with practical constraints: individual position caps, correlated group limits, platform diversification, and aggregate deployment limits.

  5. Diversification across event types, time horizons, platforms, and strategies is the primary tool for reducing portfolio variance. The diversification ratio quantifies this benefit.

  6. Risk metrics including VaR, Expected Shortfall, maximum drawdown, and risk of ruin must be adapted to the binary outcome structure of prediction markets. Monte Carlo simulation is the primary computational tool.

  7. Drawdown management requires pre-committed rules for reducing position sizes during adverse periods, automated circuit breakers, and psychological preparedness for inevitable losing streaks.

  8. Bankroll management separates trading capital from reserves and emergency funds, systematizes deposits and withdrawals, and ensures survival through adverse periods.

  9. Stress testing reveals vulnerabilities that normal-condition analysis misses: correlation spikes, edge deterioration, platform failures, and worst-case scenarios.

The unifying theme is that survival is the prerequisite for success. The best edge in the world is worthless if a drawdown wipes you out before it can compound. Portfolio construction and risk management ensure that you stay in the game long enough for your skill to produce results.


What's Next

In Chapter 18: Market Making and Liquidity Provision, we will explore the other side of prediction markets: instead of taking directional positions based on forecasting edge, we will study how to profit by providing liquidity. Market makers earn the bid-ask spread in exchange for taking on inventory risk, and we will learn how to set quotes, manage inventory, and integrate market-making with the portfolio management framework developed in this chapter.


Chapter 17 is part of Learning Prediction Markets — From Concepts to Strategies, Part III: Trading Strategies & Edge.