Adding IOC, FOK, and Stop Orders to a Matching Engine

java dev.to

A matching engine that only supports limit and market orders is a toy. Real exchanges need orders that expire, orders that demand all-or-nothing execution, and orders that sleep until a price condition is met. This article covers the implementation of time-in-force policies (GTC, IOC, FOK) and conditional stop orders (Stop-Market, Stop-Limit) in MatchEngine.

Time-in-Force: How Long Does an Order Live?

Time-in-force (TIF) controls what happens to an order's unfilled remainder after the matching attempt.

GTC: Good-Till-Cancelled

This is the default behavior that already existed. A limit order rests in the book until it is filled by a future incoming order or explicitly cancelled. Nothing changed here — we just gave it a name.

IOC: Immediate-or-Cancel

An IOC order fills as much as possible immediately, then the remainder is discarded. It never rests in the book.

Use case: a trader wants to buy up to 10 BTC at 50,000, but only if liquidity is available right now. If only 3 BTC are available, they get 3 and the remaining 7 are cancelled — not left in the book to be filled later at a potentially worse time.

The implementation is two lines in SubmitOrder, after the matching loop:

// IOC: discard any remaining quantity (never rests in book)
if order.TIF == model.IOC {
    order.Remaining = decimal.Zero
}

// Only GTC limit orders rest in the book
if !order.IsFilled() && order.Type == model.Limit {
    book.AddOrder(order)
    e.orderIndex[order.ID] = symbol
}
Enter fullscreen mode Exit fullscreen mode

By setting Remaining to zero, the subsequent IsFilled() check returns true, and the order is never added to the book. The trades that were already produced are returned normally.

FOK: Fill-or-Kill

A FOK order must be filled entirely in a single matching attempt, or it is rejected completely. No partial fills, no resting.

Use case: a trader needs exactly 100 shares to complete a hedge. Getting 50 is worse than getting 0, because a partial hedge creates unbalanced risk.

FOK requires a pre-check before matching. We cannot run the matching loop and then "undo" partial fills — the resting orders have already been modified. Instead, we check available liquidity first:

if order.TIF == model.FOK {
    if !e.canFillCompletely(book, order) {
        return nil, nil // reject silently
    }
}
Enter fullscreen mode Exit fullscreen mode

The canFillCompletely method walks the opposite side of the book, summing available quantity at compatible prices:

func (e *Engine) canFillCompletely(book *orderbook.OrderBook, order *model.Order) bool {
    available := decimal.Zero
    if order.Side == model.Buy {
        for _, ask := range book.Asks() {
            if order.Type == model.Limit && ask.Price.GreaterThan(order.Price) {
                break // past the price limit
            }
            available = available.Add(ask.Remaining)
            if available.GreaterThanOrEqual(order.Remaining) {
                return true // enough liquidity
            }
        }
    } else {
        for _, bid := range book.Bids() {
            if order.Type == model.Limit && bid.Price.LessThan(order.Price) {
                break
            }
            available = available.Add(bid.Remaining)
            if available.GreaterThanOrEqual(order.Remaining) {
                return true
            }
        }
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

Key detail: the pre-check respects price limits. A FOK buy at 100 won't count liquidity at 101 — even though a market FOK would. The check mirrors the matching loop's price constraint exactly.

If the pre-check passes, the matching loop runs normally and is guaranteed to fill the entire order (assuming no concurrent modification, which the mutex prevents).

The TIF Enum

type TimeInForce int

const (
    GTC TimeInForce = iota // default
    IOC
    FOK
)
Enter fullscreen mode Exit fullscreen mode

The zero value is GTC, which means all existing orders that don't set TIF behave exactly as before. Backward compatible by default.

Stop Orders: Sleeping Until a Price Trigger

Stop orders are fundamentally different from limit and market orders. They don't participate in matching immediately. Instead, they wait for a price condition to be met, then convert into an active order.

The Trigger Mechanism

The engine tracks the last trade price per symbol:

lastPrices map[string]decimal.Decimal
Enter fullscreen mode Exit fullscreen mode

Every time a trade is recorded, the last price is updated:

func (e *Engine) recordTrades(symbol string, trades []model.Trade) {
    for _, t := range trades {
        e.lastPrices[symbol] = t.Price
        // ... record to log, invoke callback
    }
}
Enter fullscreen mode Exit fullscreen mode

After each SubmitOrder that produces trades, the engine checks if any pending stop orders should be triggered:

if len(trades) > 0 {
    triggered := e.triggerStopOrders(symbol)
    trades = append(trades, triggered...)
}
Enter fullscreen mode Exit fullscreen mode

Stop-Market

A stop-market order becomes a market order when triggered. A buy stop triggers when the last price rises to or above the stop price. A sell stop triggers when the last price falls to or below the stop price.

func (e *Engine) isStopTriggered(stop *model.Order, lastPrice decimal.Decimal) bool {
    if stop.Side == model.Buy {
        return lastPrice.GreaterThanOrEqual(stop.StopPrice)
    }
    return lastPrice.LessThanOrEqual(stop.StopPrice)
}
Enter fullscreen mode Exit fullscreen mode

When triggered, the stop order is converted to a market order and submitted to the matching loop:

if stop.Type == model.StopMarket {
    active = model.NewMarketOrder(stop.ID, stop.Side, stop.Remaining)
}
Enter fullscreen mode Exit fullscreen mode

Stop-Limit

A stop-limit order becomes a limit order when triggered. It has both a stop price (the trigger) and a limit price (the price of the resulting limit order).

if stop.Type == model.StopLimit {
    active = model.NewLimitOrder(stop.ID, stop.Side, stop.Price, stop.Remaining)
}
Enter fullscreen mode Exit fullscreen mode

The difference matters: a stop-market order will fill at whatever price is available (potentially with slippage). A stop-limit order will only fill at the limit price or better — but it might not fill at all if the market moves past the limit.

Storage

Stop orders are stored separately from the order book:

stopOrders map[string][]*model.Order // symbol -> pending stops
Enter fullscreen mode Exit fullscreen mode

They are not in the order book because they should not be visible to other participants and should not be matchable until triggered. They are in the order index (for duplicate ID detection and cancellation).

Cancellation

Stop orders can be cancelled before they trigger:

func (e *Engine) CancelOrder(symbol, orderID string) bool {
    // Try order book first
    if ok && book.RemoveOrder(orderID) { ... }

    // Try stop orders
    stops := e.stopOrders[symbol]
    for i, s := range stops {
        if s.ID == orderID {
            e.stopOrders[symbol] = append(stops[:i], stops[i+1:]...)
            delete(e.orderIndex, orderID)
            return true
        }
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

The Trigger Loop

When stop orders are triggered, they can produce trades, which update the last price, which could trigger more stop orders. This is handled by the triggerStopOrders method processing all triggered stops in a single pass:

func (e *Engine) triggerStopOrders(symbol string) []model.Trade {
    lastPrice := e.lastPrices[symbol]
    var remaining []*model.Order
    var allTrades []model.Trade

    for _, stop := range e.stopOrders[symbol] {
        if e.isStopTriggered(stop, lastPrice) {
            // Convert to active order and match
            active := convertStop(stop)
            trades := e.match(book, active)
            e.recordTrades(symbol, trades)
            allTrades = append(allTrades, trades...)
        } else {
            remaining = append(remaining, stop)
        }
    }

    e.stopOrders[symbol] = remaining
    return allTrades
}
Enter fullscreen mode Exit fullscreen mode

Note: triggered stop orders that produce trades will update lastPrices, but we don't recursively re-check stops in the same call. This prevents cascading trigger loops. The next SubmitOrder that produces trades will check stops again.

The Request API

New convenience constructors make it easy to create all order types:

// IOC limit order
req := model.NewIOCOrderRequest(model.Buy, price, qty)

// FOK limit order
req := model.NewFOKOrderRequest(model.Buy, price, qty)

// Stop-market
req := model.NewStopMarketRequest(model.Buy, stopPrice, qty)

// Stop-limit
req := model.NewStopLimitRequest(model.Buy, stopPrice, limitPrice, qty)

// Any request can chain builders
req := model.NewLimitOrderRequest(model.Buy, price, qty).
    WithOwner("alice").
    WithTIF(model.IOC)
Enter fullscreen mode Exit fullscreen mode

How It All Fits in SubmitOrder

The updated SubmitOrder flow:

SubmitOrder(symbol, order)
│
├── Validate (nil, qty, price, stop price)
├── Normalize symbol
├── Check duplicate ID
│
├── Is stop order?
│   └── Yes → store in stopOrders, return
│
├── Is FOK?
│   └── Yes → canFillCompletely? No → return empty
│
├── Match against book
│
├── Is IOC?
│   └── Yes → set Remaining = 0
│
├── Has unfilled remainder + is Limit?
│   └── Yes → add to book (GTC only, IOC already zeroed)
│
├── Record trades, update lastPrice
│
└── Any trades produced?
    └── Yes → triggerStopOrders → append triggered trades
Enter fullscreen mode Exit fullscreen mode

Each new feature is a small branch in the flow. The matching loop itself (matchBuy, matchSell) is completely unchanged — TIF and stop logic live outside the loop.

Test Coverage

Test What It Verifies
TestIOCFillsAndDiscards IOC fills partial, discards rest
TestIOCNoMatchDiscards IOC with no liquidity produces 0 trades, 0 resting
TestIOCViaRequest IOC through SubmitRequest API
TestFOKFullFill FOK fills when enough liquidity exists
TestFOKRejectInsufficientLiquidity FOK rejected, resting orders untouched
TestFOKViaRequest FOK through SubmitRequest API
TestStopMarketBuy Buy stop triggers on price rise
TestStopMarketSell Sell stop triggers on price drop
TestStopLimitOrder Stop-limit converts to limit and matches
TestStopOrderCancel Stop cancelled before trigger
TestStopViaRequest Stop through SubmitRequest API
TestGTCIsDefault Default TIF is GTC, order rests in book

Summary

Feature Implementation
GTC Default TimeInForce zero value. No code change needed.
IOC Set Remaining = 0 after matching. 2 lines.
FOK Pre-check liquidity with canFillCompletely. ~20 lines.
Stop-Market Store in stopOrders, trigger on last price, convert to market.
Stop-Limit Same trigger mechanism, convert to limit instead.
Last price tracking Updated in recordTrades. 1 line.

The matching loop (matchBuy/matchSell) was not modified. All new behavior lives in SubmitOrder (before and after the match) and in triggerStopOrders (after trade recording). This keeps the core algorithm clean and the new features isolated.

GitHub: https://github.com/iwtxokhtd83/MatchEngine
Release: v0.6.0
Issue: #8 — Support IOC, FOK, GTC, and Stop order types

Source: dev.to

arrow_back Back to Tutorials