TL;DR — I built an options pricer in C++17 that prices a stock option two ways (Black-Scholes and Monte Carlo) and checks they agree. The fun part is the engine under it: a hand-rolled PRNG, a multithreaded Monte Carlo simulator with provably non-overlapping random streams, and constant-memory streaming statistics — ~480M simulated paths/sec across 10 cores. It's all written from the ground up, no numerical libraries doing the heavy lifting. Code's on GitHub.
Why I built it
Options pricing is one of those classic quant problems, and Monte Carlo pricing is exactly the kind of workload where C++ earns its keep — millions of independent simulations, tight numerical loops, threads fanning out across cores. I wanted a project that proved I could write that whole layer myself: my own randomness, my own parallelism, my own variance math, all the way down to the bits.
So I reached for C++ from the start and built it the hard way on purpose — not because the math is exotic, but because the engineering around it is where the real craft lives, and that's the part I wanted to get my hands dirty with.
What it does
An option is the right to buy (a call) or sell (a put) a stock at a fixed price before a fixed date. The tool prices it two ways:
- Black-Scholes — the closed-form formula. One exact price, instantly.
- Monte Carlo — simulates millions of random "possible futures" for the stock and averages the payoff.
For a normal option both should agree, and that agreement is the correctness check — two completely unrelated methods arriving at the same number is about as convincing as it gets without a reference answer to peek at. The Monte Carlo estimate lands within ~1 standard error of the analytic price and tightens toward it as you throw more paths at it.
./build/pricer --spot 100 --strike 105 --expiry 0.5 --rate 0.05 --vol 0.2 --type call
It also reports the Greeks (delta, gamma, theta, vega) and can solve backwards for implied volatility via Newton-Raphson.
Using it to spot a good buy
The tool gives a fair price; the signal is comparing it to what you'd actually pay.
AAPL $210 call, 3 months out. Tool says fair value = $8.84.
- Quoted at $7.50 → below fair value → cheap → ✅ buy
- Quoted at $11.00 → above fair value → solve implied vol → 35% vs your expected 30% → overpaying for jumpiness → ❌ pass
It won't tell you which way the stock goes — that's your call. It tells you if the price is fair.
The engine — where the C++ work is
This is the part I'm proud of. The whole point was to build the performance-critical core by hand and get it right.
Hand-rolled randomness — no <random>, no std::normal_distribution. A splitmix64 seed expander feeds a xoshiro256++ generator (~1 ns/draw, passes BigCrush), and a cached Box-Muller transform turns uniforms into normals two at a time. Doing it myself means full control over quality, speed, and — crucially — how the stream gets carved up across threads.
Provably non-overlapping parallel streams — xoshiro's jump() advances the generator 2^128 steps in O(1). Each worker thread gets its own slice of one logical stream via a successive jump, so no two threads can ever draw correlated numbers. That's the subtle bug that quietly poisons parallel Monte Carlo — designed out from the start instead of debugged later.
Constant memory — instead of allocating an N-element payoff array, a Welford accumulator streams the mean and variance in a single pass, and the per-thread accumulators merge with Chan's parallel-variance formula. Memory stays flat whether you run a thousand paths or fifty million.
Bit-for-bit reproducible parallelism — work is split into fixed contiguous blocks, each seeded by its own jump and merged in thread-index order. For a fixed seed and thread count, the result is identical no matter how the OS happens to schedule the threads. Reproducibility and parallelism usually pull against each other; here they don't, and getting that to line up was the most satisfying piece of the whole build.
It also uses antithetic variates (pair each draw Z with -Z) for variance reduction, and the whole thing compiles clean under -Wall -Wextra -Wpedantic, AddressSanitizer/UBSan, and ThreadSanitizer — no data races.
The numbers
Benchmark on a 10-core Apple Silicon machine (make bench):
| sims | threads | Mpaths/sec | abs err | ms |
|---|---|---|---|---|
| 1,000,000 | 1 | 58.2 | 0.0082 | 17.2 |
| 1,000,000 | 10 | 293.9 | 0.0083 | 3.4 (5.05×) |
| 10,000,000 | 10 | 432.1 | 0.0025 | 23.1 (5.78×) |
| 50,000,000 | 10 | 479.1 | 0.0007 | 104.4 (6.28×) |
~480M antithetic paths/sec across all cores, and the estimate tightens toward the Black-Scholes truth as the path count climbs.
Takeaway
What started as "price an option" turned into a proper little engine, and the joy of it was owning every layer — from the bits coming out of the PRNG to the variance formula merging across threads — on a workload where that control actually shows up in the numbers. It's the kind of project that's equal parts finance and systems programming, which is exactly why I enjoyed it.
Technical implementation
For the engineering-minded. C++17, zero dependencies beyond a compiler and a thread library. Makefile (-O3 -march=native) as the zero-config default, CMake for portability, plus ASan/UBSan and TSan build targets.
include/rng.hpp — randomness
splitmix64seed expander →xoshiro256++(Blackman & Vigna), ~1 ns/draw, BigCrush-clean.- Cached Box-Muller normal generation (two normals per pair of uniforms).
jump()for O(1) 2^128-step stream advance → non-overlapping per-thread substreams.
src/mc_engine.cpp — parallel simulator
- GBM terminal-price simulation with antithetic variates.
- Welford single-pass mean/variance; Chan's formula to merge per-thread accumulators → constant memory.
- Fixed contiguous work blocks seeded by successive jumps, merged in thread order → bit-for-bit reproducible for a given seed + thread count.
--threads Nsizes the pool (default = hardware concurrency).
src/bs_engine.cpp — analytic core
- Closed-form call/put prices, all four Greeks, Newton-Raphson implied-vol solver.
N(x)viastd::erf.
Native CLI: all the usual pricing flags plus --threads, --bench (paths/sec, single vs all cores), and --paths-csv for exporting sample GBM paths.
Through-line: build the hot path yourself — your own PRNG, your own threading model, your own streaming statistics — and you get speed and reproducibility on a workload where both matter.
Note: written with the help of an llm from my project + own points, dont mind the emdashes or llm-ish words :>