← back
Running Monte Carlo Simulations on Prediction Markets

I'll begin by stating that I'm increasingly worried about the prospect of losing an entire generation to gambling addiction. All around me, I'm seeing my friends (most of them young men) evaporate their earnings through a new wave of sports betting and prediction market apps we're constantly force-fed ads for. In my opinion, we're heading for a wall and it's time to wake up.

Anyways! Now that's out of the way let's begin.

Prediction markets like Polymarket assign real-money probabilities to events. For a single event the market price gives its probability (of course not perfectly, but well enough that prediction markets correctly called the 2024 presidential election when polls didn't).

The thing that's great is that there are many people betting on many different events at the same time, and some of these events may be correlated, for example: what is the probability that the US invades Iran and the Iranian regime falls and there is no nuclear deal? (btw this is the question we'll try to answer in this project) Here, since they're not independent events, naively multiplying the three probabilities ignores correlations between events and produces the wrong answer.

So how can we correctly calculate the probability of these joint events? We can use Monte Carlo simulations.

So in this project I build a correlated Monte Carlo contract pricer using a Gaussian copula model, which is applied to a dataset of 33 real Polymarket events on the ongoing Iran/Middle East situation as of early May 2026. The marginal probabilities pip_i are read directly from market prices, and the inter-event correlation matrix CC is estimated from daily price returns. The Cholesky factorization C=LL⊤C = LL^\top is precomputed once.

Here's the algorithm: Each of the MM iterations simulates one possible "world" by repeating the following four steps:

  1. Draw NN independent standard normals ε∼N(0,IN)\varepsilon \sim \mathcal{N}(0, I_N)
  2. Compute z=Lεz = L\varepsilon — a lower-triangular matrix-vector multiply that introduces the correlations between events
  3. Declare event ii as occurring if zi<τiz_i < \tau_i, where τi=Φ−1(pi)\tau_i = \Phi^{-1}(p_i) is precomputed from the market probability
  4. Check whether the contract holds — all YES events must occur and all NO events must not

The probability estimate is then

P^=number of paying worldsM,SE=P^(1−P^)M\hat{P} = \frac{\text{number of paying worlds}}{M}, \qquad \mathrm{SE} = \sqrt{\frac{\hat{P}(1-\hat{P})}{M}}

Since every iteration is fully independent, the MM worlds are embarrassingly parallel, yay!

Methods

Data Collection

I collected data (code in collect_data.py) by querying the Polymarket public API to pull live market data for all of the geopolitical events related to the Iran/Middle East situation (I used 33 in total). Events range from US-Iran military action to the Strait of Hormuz. Each event's marginal probability pip_i is taken directly from its current "yes" market price.

To estimate correlations I also fetched each market's full daily price history and aligned all the data onto a common date grid. The 33×3333 \times 33 correlation matrix CC is then computed from pairwise Pearson correlations of the daily price returns. Finally, I use numpy to compute the Cholesky factor LL and everything is serialized to data/sim_inputs.txt so that I can use the same data for all my implementations.

Serial Implementation

The serial implementation is a single-threaded C++ loop. At startup, it reads sim_inputs.txt to load the 33 market probabilities and the Cholesky factor LL, then precomputes the threshold τi=Φ−1(pi)\tau_i = \Phi^{-1}(p_i) for each event.

The main loop runs MM iterations. Each iteration draws 33 independent random numbers, applies LL to introduce the right correlations between events, and checks whether the contract conditions are met. A counter tracks how many iterations satisfy all conditions. After MM iterations, the probability estimate and standard error are printed.

OpenMP Implementation

The OpenMP version runs the MM iterations in parallel across multiple threads using a single #pragma omp parallel reduction(+:count). Each thread handles its own chunk of worlds and never needs to communicate with the others during the simulation.

To keep the random streams independent, each thread gets its own RNG seeded as seed + thread_id. The only synchronization is the final reduction on count, which OpenMP handles automatically at the end of the parallel block. Everything else is identical to the serial version.

MPI Implementation

The MPI version splits the MM worlds evenly across ranks — each gets M/sizeM / \text{size} worlds. They load sim_inputs.txt independently and run the same serial simulation loop on their slices. I also use seed + rank to keep random streams independent across ranks.

There is no communication during the simulation, so once all ranks finish a single MPI_Reduce sums the hit counts and finds the slowest rank's wall time. Rank 0 then computes and prints the final result.

CUDA Implementation

My CUDA version launches thousands of GPU threads, and I use a grid-stride loop so that they each process a subset of worlds.

The Cholesky factor is stored in constant memory (__constant__), which is broadcast to all threads in a warp for free.

Each thread accumulates a local hit count over its worlds. At the end, a shared-memory reduction combines counts within each block into a single atomicAdd to the global counter — super neat and reduces traffic by a lot.

The host side copies the final count back after cudaDeviceSynchronize.

JAX Implementation

Instead of looping over worlds one at a time, JAX draws an entire batch of worlds at once as a (batch_size, N) matrix and computes all correlated samples in a single z @ L.T.

Results

Serial Baseline

RunMWall time (s)Throughput (M worlds/s)P̂ (%)95% CI (%)Naive (%)Adjustment (pp)
110M15.4730.6463.7252[3.7134, 3.7369]2.8495+0.876
210M15.5410.6433.7252[3.7134, 3.7369]2.8495+0.876
310M15.6060.6413.7252[3.7134, 3.7369]2.8495+0.876
4100M157.9090.6333.7227[3.7190, 3.7264]2.8495+0.873

The probability estimate is stable across runs and converges as M increases. Throughput is consistent at ~0.64 M worlds/s across all runs. The correlated estimate (3.72%) is meaningfully higher than the naive multiply-events approach (2.85%).

OpenMP and MPI Scaling

Strong scaling — wall time Figure 1: Wall time for 100M worlds as thread/rank count increases.

Strong scaling — speedup Figure 2: Speedup relative to single-core serial baseline.

Strong scaling (M = 100M worlds):

ThreadsWall time (s)Throughput (M worlds/s)SpeedupP̂ (%)
1102.4620.9761.00×3.7227
251.4051.9451.99×3.7227
425.7493.8843.98×3.7242
812.8737.7687.96×3.7222
166.46615.46615.85×3.7216
324.79120.87121.39×3.7237

Weak scaling (M = threads × 10M worlds):

ThreadsMWall time (s)Throughput (M worlds/s)
110M10.2750.973
220M10.2441.952
440M10.3043.882
880M10.2927.773
16160M10.30215.531
32320M15.16121.106

Both OpenMP and MPI achieve near-ideal strong scaling up to 8 threads/ranks. At 32 workers both reach roughly 21× speedup (wall time ~5s). OpenMP consistently outperforms MPI at the same worker count because it avoids inter-process communication entirely.

Weak scaling Figure 3: Weak scaling — each worker processes 10M worlds, so total M grows with worker count.

MPI strong scaling (M = 100M worlds):

RanksWall time (s)Throughput (M worlds/s)SpeedupP̂ (%)
1103.9660.9621.00×3.7227
251.9561.9252.00×3.7227
438.8402.5752.68×3.7242
819.4625.1385.34×3.7222
169.75910.24710.65×3.7216
324.91720.33721.15×3.7237

MPI weak scaling (M = ranks × 10M worlds):

RanksMWall time (s)Throughput (M worlds/s)
110M10.4180.960
220M10.3981.923
440M15.5532.572
880M15.6085.125
16160M15.59210.262
32320M15.68220.406

Weak scaling tells a different story. OpenMP stays essentially flat up to 16 threads (~10.3s), then rises slightly to ~15s at 32 threads as memory bandwidth pressure increases. MPI jumps immediately to ~15.5s at 4 ranks and plateaus there — the overhead is dominated by process launch and barrier cost rather than compute, so adding more ranks doesn't make it worse but the initial overhead floor is higher than OpenMP.

CUDA Performance

RunMWall time (s)Throughput (M worlds/s)P̂ (%)95% CI (%)
Correctness10M0.087114.4183.7243[3.7126, 3.7360]
Benchmark100M0.855116.8983.7241[3.7204, 3.7278]
Benchmark1B8.555116.8873.7213[3.7202, 3.7225]
Benchmark10B85.545116.8973.7211[3.7208, 3.7215]

Throughput is ~116.9 M worlds/s across all M values, meaning the kernel is fully saturated from 100M worlds onward. At 10B worlds the 95% CI narrows to just ±0.0004 pp. The CUDA implementation achieves a 180× speedup over the serial baseline.

JAX Performance

JAX was used to price all four contracts across both CPU and GPU backends. Unlike the other implementations which priced a single contract, JAX's vectorized kernel prices each contract independently in a separate timed run.

CPU backend (M = 100M worlds):

ContractP̂ (%)Naive (%)Adjustment (pp)Wall time (s)Throughput (M worlds/s)
US invades ∧ Regime falls ∧ No nuclear deal3.72182.8495+0.87245.0862.2
Iran nuke ∧ NPT withdrawal ∧ No US invasion0.33450.3282+0.00645.3202.2
Full de-escalation: nuclear deal ∧ Hormuz normal ∧ No invasion14.961312.4339+2.52745.0022.2
Regional war: invasion ∧ Hormuz disrupted ∧ Kharg Island lost4.07662.1220+1.95545.0442.2

GPU backend (M = 1B worlds, NVIDIA L4):

ContractP̂ (%)Naive (%)Adjustment (pp)Wall time (s)Throughput (M worlds/s)
US invades ∧ Regime falls ∧ No nuclear deal3.72142.8495+0.87236.38927.5
Iran nuke ∧ NPT withdrawal ∧ No US invasion0.33460.3282+0.00735.94627.8
Full de-escalation: nuclear deal ∧ Hormuz normal ∧ No invasion14.956512.4339+2.52335.86327.9
Regional war: invasion ∧ Hormuz disrupted ∧ Kharg Island lost4.07872.1220+1.95735.98227.8

On GPU, JAX achieves ~27.8 M worlds/s across all four contracts — about 4× slower than the CUDA kernel on the same hardware. On CPU, JAX at 2.2 M worlds/s is faster than the serial baseline (0.64 M worlds/s).

Profiling

CUDA Nsight breakdown Figure 4: CUDA runtime breakdown from Nsight Systems (M = 100M worlds).

Nsight Systems shows that 89.2% of GPU time is spent in mc_kernel (857ms) and 10.8% in cudaMemcpyToSymbol (104ms) — that's the one-time upload of the Cholesky factor to constant memory. The kernel is fully compute-bound.

Throughput across all implementations Figure 5: Peak throughput across all five implementations (log scale).

Conclusion

This project implemented a correlated Monte Carlo contract pricer across five parallel programming models, using real Polymarket data for 33 Iran/Middle East geopolitical events. The simulation is embarrassingly parallel by nature since each world is independent, so it's really nice for testing parallel code.

The key finding from the simulation is that the correlated probability of the target contract (US invades Iran ∧ Iranian regime falls ∧ no nuclear deal) is 3.72% — meaningfully higher than the naive independent estimate of 2.85%, a +0.87 pp adjustment driven by the positive correlation between a US invasion and Iranian regime change.

And also we're doomed because everyone's addicted to sports betting, bye.