Mikey Liow

mikey's instagram content mikey at a picnic mikey speaking at an event
    things i've made

    projects

    tools & projects i've built — some for fun, some for my own convenience

    ← back to projects
    ticker % of portfolio lifetime p&l % avg price daily p&l %
    ← back to projects

    yapper lottery

    random impromptu topic yapping generator

    ?
    get in touch

    say hello

    or email at mikeyliowgianhao@gmail.com

    blog

    reads
    ← all projects

    c++ monte carlo options pricer

    project

    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

    • splitmix64 seed 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 N sizes 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) via std::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 :>

    Mikey