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
}
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
}
}
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
}
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
)
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
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
}
}
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...)
}
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)
}
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)
}
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)
}
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
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
}
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
}
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)
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
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