imc prosperity 4: how we placed top 100

neel sanimay 202615 min read
quanttradingalgorithmscompetitions

in april 2026, my team ("i am see") finished 78th globally and 25th in the us in imc prosperity 4, a two-week algorithmic trading competition with 18,803 teams. this was my first trading competition ever, and honestly my first time doing anything resembling quant work outside of coursework. i had a lot of fun. this is a round-by-round breakdown of what we built, what worked, and what didn't.

results

what prosperity is

prosperity is imc's annual algo trading competition. each round you receive new products to trade and a manual trading challenge. your python bot runs against market makers and other teams; the objective is seashells (pnl). every tick, your algorithm sees the order book and decides what to trade.

rounds run against a hidden set of market participants and bots. you get a few practice days of data to calibrate your approach, then your final submitted algorithm runs on the real hidden day. you have no idea what the hidden day looks like ahead of time — you just ship the bot and watch.

the competition is split into two phases. rounds 1 and 2 are qualifiers where you need 200k combined seashells to advance to phase 2. the leaderboard resets to zero when round 3 starts, and final placement is determined entirely by rounds 3, 4, and 5.


round 1 (qualifier)

algo products: ash-coated osmium (ASH_COATED_OSMIUM), intarian pepper root (INTARIAN_PEPPER_ROOT)

manual challenge: exchange auction. bid on dryland flax and ember mushrooms, which are bought back by the merchant guild at fixed prices ($30/unit for flax, $20/unit for mushrooms with a $0.10/unit fee). the goal is to figure out the clearing price and submit the right limit order.

qualifier pnl: $95,271 algo · $87,995 manual (doesn't count toward final placement, just needed to clear the 200k combined threshold with round 2).


round 2 (qualifier)

algo products: same as round 1, but with a twist: you could bid a one-time market access fee to unlock 25% more order book flow. the top 50% of bids across all teams got the extra access, with the fee deducted from your round 2 profits.

manual challenge: allocate a 50,000 XIRECs budget across three outpost growth pillars to maximize returns.

qualifier pnl: $93,849 algo · $24,233 manual (combined with round 1 this put us comfortably past 200k and into phase 2).


round 3

products: velvetfruit extract (VEX), hydrogel pack, and 10 european call options on VEX with strikes 4000-6500

this was the first round with real derivatives, and the one i was most excited about going in. the options were european calls on VEX with fixed strikes, fixed expiry, and cash settlement in seashells. position limits were 200 on the commodities and 300 on each option contract. algo pnl: $9,759.91 · manual pnl: $75,410.

algorithmic

the core challenge was figuring out how to quote options without getting destroyed by adverse selection. the naive approach of quoting around the black-scholes theoretical price fails fast, because you don't know the right vol and the "informed" bots in the simulation know more than you do.

our solution was to treat the outer book walls (the deepest resting bids and asks rather than the best bid/ask) as the fair value anchor for passive strikes. for ITM options (strikes 4000-5200), the books are wide and stable. the walls are slow-moving and reflect equilibrium better than the noisy inside spread.

# wall midpoint MM: anchor to outer book, not best bid/ask
wall_bid = min(depth.buy_orders)   # worst (lowest) resting bid
wall_ask = max(depth.sell_orders)  # worst (highest) resting ask
wall_mid = (wall_bid + wall_ask) / 2

# quote one tick inside the wall
orders.append(Order(symbol, int(wall_mid) - 1, buy_size))
orders.append(Order(symbol, int(wall_mid) + 1, -sell_size))

on top of that we ran three additional layers:

prior trade capture. if the last executed trade on a product happened at a price still inside the current spread, we'd post a limit order there to catch continuation flow. pure liquidity provision — we never cross the book — but it picks up external traders repeating the same fill.

rolling z-score mean reversion. for each option, we maintained an 800-1500 tick rolling window of mid-prices (longer windows for deeper OTM strikes where moves are rarer). when the z-score exceeded +-2.2 to +-2.5, we'd take a position against the move at 4-10 lots. option mids revert hard intraday, especially on the ATM strikes.

if len(price_history) >= window:
    mean = sum(price_history[-window:]) / window
    variance = sum((x - mean) ** 2 for x in price_history[-window:]) / window
    std = variance ** 0.5
    z = (current_mid - mean) / std if std > 0 else 0.0

    if z < -Z_LO:
        orders.append(Order(symbol, ask, revert_size))  # buy the dip
    elif z > Z_HI:
        orders.append(Order(symbol, bid, -revert_size)) # sell the rip

VEX and hydrogel EMA mean-reversion. for the underlying commodities, we tracked slow EMAs (alpha 0.001-0.002) as fair value proxies and took at size 50 when price deviated enough. for VEX we also read order flow imbalance: if the top-of-book was heavily bid-weighted we'd suppress our ask quotes and vice versa, reducing adverse selection on the passive side.

the result was a modestly profitable round but not a great one. the z-score reversion was real alpha. the wall MM worked but required careful handling of the deep ITM contracts (4000/4500) which behaved more like delta-1 instruments than options. we were leaving money on the table by not adapting to directional moves in VEX, which round 4 fixed.


round 4

products: same as round 3 (VEX, hydrogel, 10 options)

round 4 used the same product set but the hidden day had a very different character. there were clear directional trends in VEX price rather than pure chop, and our round 3 bot was a mean-reversion machine. it got hurt badly early on. this round was where i learned the most. algo pnl: $85,393.35 · manual pnl: $0 — an 8.75x algo improvement over round 3.

algorithmic

the fix was market regime detection. we added a dual-EMA trend layer on VEX:

FAST_ALPHA = 0.015   # responsive to recent price action
SLOW_ALPHA = 0.0015  # slow-moving reference level

fast_ema = fast_ema + FAST_ALPHA * (mid - fast_ema)
slow_ema = slow_ema + SLOW_ALPHA * (mid - slow_ema)
trend = fast_ema - slow_ema  # positive = uptrend, negative = downtrend

we then classified the market into regimes based on both the absolute deviation from our anchor price (5250) and the trend:

VEX_ANCHOR = 5250

dev = slow_ema - VEX_ANCHOR

if dev >= 45 and trend <= 8:
    regime = -1   # bearish: price extended high AND momentum weakening
elif dev <= -45 and trend >= -24:
    regime = +1   # bullish: price extended low AND momentum weakening
else:
    regime = 0    # neutral

the key insight is the asymmetry: we require weakening momentum to enter a mean-reversion regime. if price is high and accelerating upward, we stay neutral. we only fade the move once the fast EMA starts decelerating relative to the slow one. no catching falling knives.

regime classification then controlled position caps across all option strikes:

regimelong capshort capbias
bearish (-1)12020stay short
neutral (0)30020balanced
bullish (+1)3000stay long
bear trend12020fade longs
bull trend3000fade shorts

in strong regimes, we also added directional takes — actively buying or selling option spreads at 30 lots when the regime signal was high conviction. this let us capture the directional move itself, not just wait for the reversion.

the z-score reversion and wall MM from round 3 ran unchanged underneath. the regime layer sat on top and modified which trades were allowed. the improvement was almost entirely from avoiding mean-reversion trades on the wrong side of the trend.


round 5

products: 50 new equity-like securities across 10 product families (galaxy sounds, microchip, oxygen shake, pebbles, robot, sleep pod, translator, snackpack, panel, UV visor)

this was the biggest round and the most fun to think through. instead of derivatives, we got 50 spot instruments organized into correlated families of 5 products each. position limit was 10 lots per product, tight, but with 50 products that's 500 lots of total capacity. the simulation ran for about 1 million ticks. algo pnl: $415,534.45 · manual pnl: $69,015 — 4.9x over round 4 and about 42x over round 3.

algorithmic

the product structure was the alpha. within each family, prices were highly correlated and exhibited mean-reverting spreads. the job was to find the structural relationships, trade them, and wrap everything in a market-making layer that earned the spread on products without cleaner signals.

our final strategy stacked five layers, executed in priority order each tick:


layer 0: at-touch market making

the baseline. for every product not claimed by a higher-priority layer, post a passive bid at best_bid and a passive ask at best_ask with inventory-aware sizing:

buy_size  = max(0, LIMIT - position)   # up to 10 when flat
sell_size = max(0, LIMIT + position)   # up to 20 when short 10

if buy_size > 0:
    orders.append(Order(symbol, bid, buy_size))
if sell_size > 0:
    orders.append(Order(symbol, ask, -sell_size))

we tested inside-spread quoting (bid+1 / ask-1 when spread > 2) in an earlier version. it lost about $71k compared to at-touch across the three sample days. adverse selection on equity-like instruments is broad enough that you need the full spread to break even on passive fills. at-touch, boring as it sounds, is correct here.

we also skipped MM entirely on three products (SLEEP_POD_LAMB_WOOL, PANEL_1X2, SNACKPACK_VANILLA) because they lost money on every single sample day even at touch. probably stale quotes or toxic flow. they could still be traded by the directional layers.

layer 1: order book imbalance lean

before placing passive quotes, we filtered them by visible book imbalance:

bid_vol = sum(depth.buy_orders.values())
ask_vol = sum(-v for v in depth.sell_orders.values())
imbalance = (bid_vol - ask_vol) / (bid_vol + ask_vol)

if imbalance > 0.05:     # book is heavy on the bid side
    cancel passive buy   # don't add to a crowded bid
if imbalance < -0.05:    # book is heavy on the ask side
    cancel passive sell  # don't add to a crowded ask

contrarian to visible order book pressure: if everyone is trying to buy, we don't join the passive bid queue. the effect was small but consistently positive.

layer 2: short-horizon drift mean-reversion

track the last 5 ticks of mid prices. if the 5-tick drift exceeds +-150 points, take an aggressive position against the move:

if len(mid_history) > DRIFT_LB:
    drift = mid_history[-1] - mid_history[-DRIFT_LB - 1]
    if drift > 150:
        orders.append(Order(symbol, bid, -sell_size))   # sell into the spike
    elif drift < -150:
        orders.append(Order(symbol, ask, buy_size))     # buy into the dip

in practice this fired almost exclusively on PEBBLES_XL which had bot-driven jumps large enough to trigger. for OXYGEN_SHAKE_CHOCOLATE we used a tighter version — threshold of 100 points over 5 ticks triggering a 7-lot fade, because the chocolate shake had a distinct short-horizon reversion pattern stronger than the others.

layer 3: EWMA pair spread mean-reversion

the most structurally robust layer. for each cointegrated pair, maintain an online EWMA of the spread and trade when the z-score is extreme:

PAIR_ALPHA = 0.001      # very slow; spread is near-stationary
PAIR_Z_THR  = 3.0       # only trade on strong z-scores
PAIR_TAKE   = 3         # 3 lots per leg

d = spread - ewma_mean
ewma_mean  += PAIR_ALPHA * d
ewma_var    = (1 - PAIR_ALPHA) * ewma_var + PAIR_ALPHA * d * d
z = d / sqrt(ewma_var)

if z > PAIR_Z_THR:      # spread too high: sell a, sell b (for sum pairs)
    pair_signal[a] -= 1
    pair_signal[b] -= 1
elif z < -PAIR_Z_THR:   # spread too low: buy a, buy b
    pair_signal[a] += 1
    pair_signal[b] += 1

we ran this on seven "sum" pairs (price of a + price of b is stationary) and two "diff" pairs (price of a minus price of b is stationary):

sum pairs:

  • ROBOT_MOPPING + ROBOT_VACUUMING
  • TRANSLATOR_GRAPHITE_MIST + TRANSLATOR_SPACE_GRAY
  • PEBBLES_XL + PEBBLES_XS
  • OXYGEN_SHAKE_CHOCOLATE + OXYGEN_SHAKE_MINT
  • SNACKPACK_RASPBERRY + SNACKPACK_VANILLA
  • SLEEP_POD_COTTON + SLEEP_POD_LAMB_WOOL
  • GALAXY_SOUNDS_DARK_MATTER + GALAXY_SOUNDS_SOLAR_FLAMES

diff pairs:

  • ROBOT_DISHES - ROBOT_MOPPING
  • SNACKPACK_CHOCOLATE - SNACKPACK_PISTACHIO

PAIR_ALPHA = 0.001 gives an effective lookback of about 1000 ticks. the z-score threshold of 3.0 is conservative — we only trade when the spread is very extended. this keeps false positives low and made the pair layer positive on all three sample days.

layer 4: panel structural spread trading

the panel products had the cleanest structural relationship in the whole set. panel prices follow physical size scaling laws, so a 2x4 panel relates to a 1x4 through a specific ratio. we estimated two regression spreads and hardcoded the rounded coefficients:

PANEL_SPREADS = (
    # (left, right, beta, mean, std)
    ("PANEL_1X2", "PANEL_1X4",  -0.22, 11000.0, 560.0),
    ("PANEL_2X2", "PANEL_4X4",  -0.73, 16800.0, 590.0),
)

PANEL_ENTRY_Z = 1.0
PANEL_EXIT_Z  = 0.25

z = (mids[left] - beta * mids[right] - mean) / std

if z > PANEL_ENTRY_Z:
    side = -1   # spread too high: short left, long right
elif z < -PANEL_ENTRY_Z:
    side = +1   # spread too low: long left, short right

if abs(z) < PANEL_EXIT_Z:
    side = 0    # spread normalized: exit

we deliberately used rounded coefficients (-0.22, -0.73) rather than exact fitted values. fitting exact decimals from three days of data adds overfit risk without meaningful edge. the rounded versions cost less than 2k in sample and buy real robustness. the full spread contributed about 30k+ per day and was stable across all three sample days.

layer 5: persistent directional targets

the most opinionated layer. for nine products, we had high-conviction structural views and simply held them at max position all day:

producttargetrationale
GALAXY_SOUNDS_BLACK_HOLES+10persistent upward drift every sample day
MICROCHIP_OVAL-10persistent downward drift every sample day
OXYGEN_SHAKE_GARLIC+10strong and consistent daily trend
PEBBLES_XS-10family sum constraint: XS persistently sells off
ROBOT_VACUUMING-10persistent short bias in sample data
SLEEP_POD_LAMB_WOOL+10positive all three sample days despite MM losses
UV_VISOR_AMBER-10persistent short
UV_VISOR_MAGENTA+10persistent long
UV_VISOR_RED+10persistent long

for several more products, we used the day's opening price to decide direction. if the opening mid was below a threshold, go long; above, go short. we kept only the thresholds that were positive in leave-one-day-out testing across all three public days. it's very easy to overfit 25 threshold rules to three training days, so we filtered aggressively.

what the results showed

the panel spreads were the most structurally sound because physical size relationships are real constraints, not statistical patterns that can disappear. the EWMA pairs were second because the family sum / family diff properties are near-mathematical in some product families (pebbles especially: the five products in that family sum to an almost constant level across days).

the directional targets contributed the most raw PnL but with more variance, which is exactly what you'd expect from strategies one step removed from pure structure. three days of validation is thin, and some of those directional bets will misfire on the hidden day.

the at-touch MM layer was the floor. it ran on every product without a stronger signal and earned consistently by providing liquidity.


the numbers

qualifier rounds (phase 1, don't count toward final placement)

roundalgo pnlmanual pnl
r1$95,271$87,995
r2$93,849$24,233

phase 2 — final placement

roundalgo pnlmanual pnl
r3$9,759.91$75,410
r4$85,393.35$0
r5$415,534.45$69,015
total$510,687.71$144,425

we finished with $655,112 in total pnl, placing 78th out of 18,803 teams globally and 25th nationally (United States).


takeaways

  1. mean-reversion is the backbone. in rounds 3 and 4 we made money on z-score reversion of option mids. in round 5 we made money on drift fade, pair spreads, and panel structural spreads. all mean-reversion in different forms. the market rewards patience when you have a real structural anchor.

  2. regime detection is what separates ok from good. the 8.75x improvement from round 3 to round 4 came almost entirely from adding regime detection — not better MM logic, not better parameters, just knowing when not to trade mean-reversion. a model that knows its own preconditions will always beat one that doesn't.

  3. structure beats statistics. the highest-conviction edges in round 5 were the panel structural spreads (physical size relationships are real) and the pebbles family sum (a product design constraint). these worked because they were grounded in something real, not pattern-mined correlations.

  4. at-touch beats clever. we spent real time testing inside-spread quoting. it was worse. when adverse selection is broad, you need the full spread to compensate for the bad fills you're going to take. the boring strategy was right.

  5. overfit kills. we had plenty of candidate strategies that improved sample performance but failed leave-one-day-out. every round we ran explicit LOO tests before finalizing. this cost some in-sample PnL but bought robustness. in a competition with one hidden test day, that trade is almost always worth it.

for a first competition, i couldn't have asked for a better experience. would absolutely do it again.