Skip to content

Order Types, Time-in-Force and Pre-Trade Checks

Add IOC, FOK, day and post-only orders to the engine, validate every order before it reaches the book, prevent self-trades, enforce risk limits and compute an auction price.

One order, many instructions

The engine from the matching engine lesson understands two kinds of order: a limit order, whose remainder rests until someone cancels it, and a market order, whose remainder is dropped. Real participants need more ways to say what they want.

A trader who wants 500 lots now, and not a stray remainder sitting in the book for the rest of the day, needs "fill what you can immediately and cancel the rest". A trader hedging, that is, trading to offset the risk of another position, may need "all 500 or none at all", because a partial fill leaves part of the risk uncovered. A market maker, a participant who keeps both a bid and an ask in the book, needs "rest only; if this would trade on arrival, refuse it". And every venue needs to refuse orders that are malformed or dangerous before they touch the book: a price that is not on the price grid, a quantity of 10 million when the trader meant 10,000, a buyer accidentally trading with its own resting sell.

This lesson adds those instructions and checks in three layers:

  1. Order types and time in force, which change how the engine treats an order: immediate-or-cancel, fill-or-kill, day, good-till-cancelled and post-only.
  2. Validation and self-trade prevention, which a venue applies to every order: tick size, lot size, price bands, maximum size, and what to do when two orders from the same owner would trade.
  3. Pre-trade risk checks, a separate layer in front of the engine that enforces limits per account: position, order value and message rate.

The last section covers a different way of matching altogether, the auction, used to open and close many markets. After that, the next lesson turns to the other direction of traffic: the binary market-data feed that tells the world what the engine did.

Time in force: how long an order stays alive

Time in force says how long an order remains eligible to trade. The FIX protocol, a widely used message standard for order entry, lists the common values in its TimeInForce field (tag 59). The ones that matter for matching:

Time in forceWhat happens to the part that does not trade on arrival
Good-till-cancelled (GTC)Rests until filled or cancelled
DayRests until filled, cancelled or the end of the trading day
Immediate-or-cancel (IOC)Cancelled at once; never rests
Fill-or-kill (FOK)The whole order trades at once, or none of it does

The Xetra market model defines IOC and FOK the same way: an IOC order "is executed immediately and fully or as fully as possible", with non-executed parts deleted, while an FOK order "is executed immediately and fully or not at all". GTC and day are about lifetime; IOC and FOK are about what happens on arrival. FIX also defines further values, such as good-till-date and at-the-opening, which this lesson does not implement.

A separate flag, post-only, constrains the other direction: the order may only rest, never take. It exists for makers, participants who want to add liquidity to the book (for example because a venue's fees can differ for makers and takers, or because a quote that trades on arrival was based on a price that has already moved). If a post-only order would trade on arrival, venues do one of two things. Xetra's version, called book-or-cancel, rejects it. Others reprice it to one tick away from the opposite best price so that it can rest. The engine here rejects.

Implementing these is a short list of changes around the matching loop:

  • Post-only: before matching, ask "is this order marketable?", meaning does its limit reach the best opposite price. If yes, reject.
  • FOK: before matching, add up the opposite quantity at acceptable prices. If it is less than the order's quantity, reject without touching the book. This check is what makes FOK all-or-nothing; matching first and undoing afterwards would publish trades that never should have happened.
  • IOC: match as usual, then cancel any remainder instead of resting it.
  • Day: rest as usual, remember the id, and cancel all remembered orders at end_of_day().

Time in force in code

The order and trade types are the course's shared definitions. A small Request wraps an Order with its instructions, so the shared Order stays the same in every lesson.

main.cppc++23
#include <algorithm>
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <print>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>

using Price   = std::int64_t;   // integer ticks (a multiple of the tick size);
                                //   never a double
using Qty     = std::int64_t;   // whole lots
using OrderId = std::uint64_t;

enum class Side : std::uint8_t { Buy, Sell };

struct Order { OrderId id; Side side; Price price; Qty qty; };
struct Trade { OrderId taker; OrderId maker; Price price; Qty qty; };

enum class TimeInForce : std::uint8_t { Gtc, Day, Ioc, Fok };
struct Request {
    Order order;
    TimeInForce tif = TimeInForce::Gtc;
    bool post_only = false;
};

std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
bool crosses(const Order& o, Price p) {  // would o trade at price p?
    return o.side == Side::Buy ? p <= o.price : p >= o.price;
}

class MatchingEngine {
public:
    std::vector<std::string> log;

    std::vector<Trade> submit(const Request& r) {
        Order o = r.order;
        std::vector<Trade> trades;
        if (o.qty <= 0 || where_.contains(o.id)) return say(o, "rejected", trades);
        if (r.post_only && marketable(o))
            return say(o, "post-only would trade: rejected", trades);
        if (r.tif == TimeInForce::Fok && available(o) < o.qty)
            return say(o, std::format("FOK: only {} available: killed", available(o)),
                       trades);
        if (o.side == Side::Buy) match(o, asks_, trades);
        else match(o, bids_, trades);
        if (o.qty == 0) return trades;
        if (r.tif == TimeInForce::Ioc) return say(o, "IOC remainder cancelled", trades);
        (o.side == Side::Buy ? bids_[o.price] : asks_[o.price]).push_back(o);
        where_[o.id] = {o.side, o.price};
        if (r.tif == TimeInForce::Day) day_orders_.insert(o.id);
        return say(o, "rests", trades);
    }
    bool cancel(OrderId id) {
        auto it = where_.find(id);
        if (it == where_.end()) return false;
        auto [side, price] = it->second;
        where_.erase(it);
        if (side == Side::Buy) remove(bids_, price, id);
        else remove(asks_, price, id);
        return true;
    }
    void end_of_day() {
        for (OrderId id : day_orders_)  // a std::set: same order on every run
            if (cancel(id)) log.push_back(std::format("end of day: [{}] cancelled", id));
        day_orders_.clear();
    }
    void print_book() const {
        for (auto it = asks_.rbegin(); it != asks_.rend(); ++it)
            std::println("  {:>7} {:>10} {}", px(it->first), "", ids(it->second));
        for (const auto& [price, q] : bids_)
            std::println("  {:>7} {:>10}", px(price), ids(q));
    }

private:
    bool marketable(const Order& o) const {
        if (o.side == Side::Buy)
            return !asks_.empty() && crosses(o, asks_.begin()->first);
        return !bids_.empty() && crosses(o, bids_.begin()->first);
    }
    Qty available(const Order& o) const {  // stops counting once there is enough
        return o.side == Side::Buy ? sum(asks_, o) : sum(bids_, o);
    }
    template <class Levels> static Qty sum(const Levels& book, const Order& o) {
        Qty total = 0;
        for (const auto& [price, queue] : book) {
            if (!crosses(o, price)) break;
            for (const Order& m : queue)
                if ((total += m.qty) >= o.qty) return total;
        }
        return total;
    }
    std::vector<Trade>& say(const Order& o, const std::string& what,
                            std::vector<Trade>& t) {
        log.push_back(std::format("  [{}] {}{}", o.id, what,
                                  o.qty > 0 ? std::format(" ({} lots)", o.qty) : ""));
        return t;
    }
    template <class Levels>
    void match(Order& taker, Levels& book, std::vector<Trade>& out) {
        while (taker.qty > 0 && !book.empty() && crosses(taker, book.begin()->first)) {
            auto level = book.begin();
            auto& queue = level->second;
            while (taker.qty > 0 && !queue.empty()) {
                Order& maker = queue.front();
                Qty q = std::min(taker.qty, maker.qty);
                out.push_back({taker.id, maker.id, level->first, q});
                log.push_back(std::format("  [{}] trades {} with [{}] @ {}", taker.id, q,
                                          maker.id, px(level->first)));
                taker.qty -= q;
                if ((maker.qty -= q) == 0) {
                    where_.erase(maker.id);
                    queue.pop_front();
                }
            }
            if (queue.empty()) book.erase(level);
        }
    }
    template <class Levels> static void remove(Levels& levels, Price price, OrderId id) {
        auto level = levels.find(price);
        std::erase_if(level->second, [&](const Order& o) { return o.id == id; });
        if (level->second.empty()) levels.erase(level);
    }
    static std::string ids(const std::deque<Order>& q) {
        std::string s;
        for (const Order& o : q) s += std::format("[{}] {} ", o.id, o.qty);
        return s;
    }
    struct Where { Side side; Price price; };
    std::map<Price, std::deque<Order>, std::greater<>> bids_;
    std::map<Price, std::deque<Order>> asks_;
    std::unordered_map<OrderId, Where> where_;
    std::set<OrderId> day_orders_;
};

int main() {
    using enum TimeInForce;
    MatchingEngine e;
    e.submit({{1, Side::Sell, 10002, 4}});
    e.submit({{2, Side::Sell, 10003, 6}});
    e.submit({{3, Side::Buy, 10000, 5}});
    e.log.clear();
    std::println("IOC, FOK, post-only and day orders against this book:");
    e.print_book();

    const Request requests[] = {
        {{10, Side::Buy, 10002, 6}, Ioc},        // takes 4, cancels 2
        {{11, Side::Buy, 10003, 8}, Fok},        // only 6 available
        {{12, Side::Buy, 10003, 6}, Fok},        // exactly 6 available
        {{13, Side::Sell, 10000, 2}, Gtc, true}, // post-only, would trade with [3]
        {{14, Side::Sell, 10001, 2}, Gtc, true}, // post-only, rests
        {{15, Side::Buy, 10000, 2}, Day},        // rests behind [3]
    };
    for (const Request& r : requests) e.submit(r);
    e.end_of_day();
    for (const std::string& line : e.log) std::println("{}", line);
    std::println("book at the end of the day:");
    e.print_book();
}
Output

Press Run to compile and run this example.

IOC, FOK, post-only and day orders against this book:
   100.03            [2] 6
   100.02            [1] 4
   100.00     [3] 5
  [10] trades 4 with [1] @ 100.02
  [10] IOC remainder cancelled (2 lots)
  [11] FOK: only 6 available: killed (8 lots)
  [12] trades 6 with [2] @ 100.03
  [13] post-only would trade: rejected (2 lots)
  [14] rests (2 lots)
  [15] rests (2 lots)
end of day: [15] cancelled
book at the end of the day:
   100.01            [14] 2
   100.00     [3] 5

Read the log against the book. The IOC order [10] took the 4 lots at 100.02 and its other 2 lots vanished instead of resting. The FOK order [11] wanted 8 lots but only 6 were available at or below 100.03, so it was killed and nothing traded; [12], asking for exactly those 6, filled completely. The post-only sell [13] at 100.00 would have hit the bid [3] and was refused, while [14] at 100.01 rested and became the new best ask. The day order [15] rested behind [3] and was cancelled by end_of_day(), and the GTC orders [3] and [14] survived it.

Each instruction ends up as one or two lines of code around the unchanged matching loop. The FOK check deserves a closer look at its cost. available walks the same levels and orders that matching would walk, but it stops as soon as it has counted enough, so a successful FOK costs at most about twice a plain limit order, and a killed one costs a scan with no changes. Because the check happens before any trade, a killed FOK leaves the book exactly as it was, which is the property the test at the end of this lesson verifies on thousands of random orders.

Two other order types are common enough to describe, though we do not implement them. A stop order is dormant until the market trades at or through a trigger price (the stop price), and then enters the book as a market or limit order; the Xetra market model describes both variants. Because it needs the last trade price, a stop order needs the engine to keep one more piece of state and to re-run matching when a trade triggers stops, which can trigger further stops. An iceberg (or reserve) order shows only part of its quantity, the peak. In the Xetra model, when the visible peak is fully executed and hidden quantity remains, a new peak enters the book with a new time stamp, so the hidden part does not keep its time priority. Both are good exercises once the basic engine is solid.

Validation: refuse what cannot be right

Every order is validated before the engine sees it. These checks do not depend on who sent the order or on the state of the book (except a reference price); they depend on the instrument's rules.

  • Quantity must be positive and a multiple of the lot size. If the lot is 10, an order for 25 is refused.
  • Price must be on the tick grid. In this course a Price counts the system's smallest unit, 0.01. An instrument's tick size is a property of that instrument: with a tick of 0.05, valid prices are multiples of 5 units, so 100.05 is fine and 100.03 is not.
  • Maximum order size. A quantity far beyond anything normal is more likely a typing error ("fat finger") than an intention.
  • Price band. The limit must be within some distance of a reference price, such as the last trade price. A buy limit at 150.00 when the market is at 100.00 would trade through every ask up to 150.00.
  • Unique id. An order id already resting is refused.

The return type matters as much as the checks. A validation function that returns bool tells the caller nothing about why an order failed. Returning std::expected<Accepted, Reject> forces the caller to handle failure, and the Reject carries an explicit enum class RejectReason plus a human-readable detail. Every reject then produces a log line someone can act on, and the reasons can be counted: a sudden rise in OutsideBand rejects means something different from a rise in OffTick rejects.

main.cppc++23
#include <cstdint>
#include <expected>
#include <format>
#include <map>
#include <print>
#include <set>
#include <string>
#include <string_view>

using Price   = std::int64_t;   // integer ticks (a multiple of the tick size);
                                //   never a double
using Qty     = std::int64_t;   // whole lots
using OrderId = std::uint64_t;

enum class Side : std::uint8_t { Buy, Sell };

struct Order { OrderId id; Side side; Price price; Qty qty; };

std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }

enum class RejectReason : std::uint8_t {
    BadQuantity, OddLot, TooLarge, OffTick, OutsideBand, DuplicateId
};
std::string_view name(RejectReason r) {
    switch (r) {
        case RejectReason::BadQuantity: return "BadQuantity";
        case RejectReason::OddLot:      return "OddLot";
        case RejectReason::TooLarge:    return "TooLarge";
        case RejectReason::OffTick:     return "OffTick";
        case RejectReason::OutsideBand: return "OutsideBand";
        case RejectReason::DuplicateId: return "DuplicateId";
    }
    return "?";
}

struct Reject { RejectReason reason; std::string detail; };
struct Accepted { Order order; };

struct Instrument {
    Price tick = 5;        // 0.05 in units of 0.01
    Qty lot = 10;
    Qty max_qty = 5'000;
    Price reference = 10000;  // last trade: 100.00
    int band_percent = 5;     // limits must be within 5% of the reference
};

std::expected<Accepted, Reject> validate(const Order& o, const Instrument& ins,
                                         const std::set<OrderId>& live) {
    auto fail = [](RejectReason r, std::string detail) {
        return std::unexpected(Reject{r, std::move(detail)});
    };
    if (live.contains(o.id)) return fail(RejectReason::DuplicateId, "id is in use");
    if (o.qty <= 0) return fail(RejectReason::BadQuantity, "quantity must be positive");
    if (o.qty % ins.lot != 0)
        return fail(RejectReason::OddLot, std::format("not a multiple of {}", ins.lot));
    if (o.qty > ins.max_qty)
        return fail(RejectReason::TooLarge, std::format("above maximum {}", ins.max_qty));
    if (o.price % ins.tick != 0)
        return fail(RejectReason::OffTick,
                    std::format("not a multiple of {}", px(ins.tick)));
    // |price - reference| <= reference * band / 100, kept in integers
    Price distance = o.price > ins.reference ? o.price - ins.reference
                                             : ins.reference - o.price;
    if (distance * 100 > ins.reference * ins.band_percent)
        return fail(RejectReason::OutsideBand,
                    std::format("{} is more than {}% from {}", px(o.price),
                                ins.band_percent, px(ins.reference)));
    return Accepted{o};
}

int main() {
    Instrument ins;
    std::set<OrderId> live{7};  // order 7 is already resting
    const Order orders[] = {
        {1, Side::Buy, 10005, 100},     {2, Side::Buy, 10003, 100},
        {3, Side::Sell, 10010, 25},     {4, Side::Sell, 10010, 50'000},
        {5, Side::Buy, 11000, 10},      {6, Side::Sell, 9500, 10},
        {7, Side::Buy, 10000, 10},      {8, Side::Buy, 10000, 0},
    };
    std::map<RejectReason, int> counts;
    for (const Order& o : orders) {
        auto result = validate(o, ins, live);
        std::string what = std::format("[{}] {} {} @ {}", o.id,
                                       o.side == Side::Buy ? "buy" : "sell", o.qty,
                                       px(o.price));
        if (result) {
            std::println("{:<26} accepted", what);
        } else {
            std::println("{:<26} {}: {}", what, name(result.error().reason),
                         result.error().detail);
            ++counts[result.error().reason];
        }
    }
    std::println("rejects by reason:");
    for (const auto& [reason, n] : counts) std::println("  {:<12} {}", name(reason), n);
}
Output

Press Run to compile and run this example.

[1] buy 100 @ 100.05       accepted
[2] buy 100 @ 100.03       OffTick: not a multiple of 0.05
[3] sell 25 @ 100.10       OddLot: not a multiple of 10
[4] sell 50000 @ 100.10    TooLarge: above maximum 5000
[5] buy 10 @ 110.00        OutsideBand: 110.00 is more than 5% from 100.00
[6] sell 10 @ 95.00        accepted
[7] buy 10 @ 100.00        DuplicateId: id is in use
[8] buy 0 @ 100.00         BadQuantity: quantity must be positive
rejects by reason:
  BadQuantity  1
  OddLot       1
  TooLarge     1
  OffTick      1
  OutsideBand  1
  DuplicateId  1

Order [6] sells at 95.00, exactly 5% below the reference, and is accepted: the band is inclusive, and whether a boundary is inside or outside is one of the decisions a rulebook has to state explicitly. Notice also two choices in validate. The checks run in a fixed order, so an order with several problems always reports the same one first; that keeps logs and counts stable. And the band check stays in integers: rather than computing a percentage as a double, it compares distance * 100 with reference * band_percent. The products are small here; for a production system the same overflow reasoning as the notional check of the last lesson applies.

Self-trade prevention

A self-trade happens when an incoming order would trade against a resting order from the same owner. Nothing changes hands economically, but a trade is printed and published, which misrepresents how much real trading is taking place. Venues therefore offer self-trade prevention (STP): when the engine is about to match two orders with the same owner identifier, it does something else instead.

The common modes, as described for example in Coinbase's exchange documentation:

ModeWhen the incoming order meets its own resting order
Cancel newestCancel the incoming order (its remainder); the resting order stays
Cancel oldestCancel the resting order; the incoming order continues matching
Cancel bothCancel both orders
DecrementReduce both by the smaller quantity, cancelling whichever reaches zero

The Xetra market model's self-match prevention behaves like the decrement mode: equal quantities cancel both orders, and otherwise the smaller order is cancelled and the larger one reduced. In every mode, the incoming order may still trade with other owners' orders that come before its own in priority.

STP lives inside the matching loop, because only there does the engine know which resting order is next. The engine below adds an account to each order, kept in a map beside the book so that Order stays unchanged, and implements all four modes so that you can compare them on the same book.

main.cppc++23
#include <algorithm>
#include <cstdint>
#include <deque>
#include <format>
#include <map>
#include <print>
#include <string>
#include <unordered_map>
#include <vector>

using Price   = std::int64_t;   // integer ticks (a multiple of the tick size);
                                //   never a double
using Qty     = std::int64_t;   // whole lots
using OrderId = std::uint64_t;

enum class Side : std::uint8_t { Buy, Sell };

struct Order { OrderId id; Side side; Price price; Qty qty; };

using Account = char;
enum class Stp : std::uint8_t { CancelNewest, CancelOldest, CancelBoth, Decrement };

std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }

// Only buy orders arrive in this demo, so only the asks are needed.
class Engine {
public:
    explicit Engine(Stp mode) : mode_(mode) {}

    void rest_sell(Order o, Account a) {
        asks_[o.price].push_back(o);
        owner_[o.id] = a;
    }
    void buy(Order taker, Account a) {
        while (taker.qty > 0 && !asks_.empty() && asks_.begin()->first <= taker.price) {
            auto level = asks_.begin();
            auto& queue = level->second;
            while (taker.qty > 0 && !queue.empty()) {
                Order& maker = queue.front();
                if (owner_.at(maker.id) == a) {
                    self_match(taker, maker, queue);
                    continue;
                }
                Qty q = std::min(taker.qty, maker.qty);
                std::println("  trade [{}] x [{}] {} @ {}", taker.id, maker.id, q,
                             px(level->first));
                taker.qty -= q;
                if ((maker.qty -= q) == 0) queue.pop_front();
            }
            if (queue.empty()) asks_.erase(level);
        }
        if (taker.qty > 0) std::println("  [{}] rests {} @ {}", taker.id, taker.qty,
                                        px(taker.price));
    }
    void print_asks() const {
        std::string s;
        for (const auto& [price, queue] : asks_)
            for (const Order& o : queue)
                s += std::format("[{}] {} @ {}  ", o.id, o.qty, px(price));
        std::println("  asks left: {}", s.empty() ? "none" : s);
    }

private:
    void self_match(Order& taker, Order& maker, std::deque<Order>& queue) {
        std::println("  [{}] meets own order [{}]", taker.id, maker.id);
        bool drop_taker = false, drop_maker = false;
        switch (mode_) {
            case Stp::CancelNewest: drop_taker = true; break;
            case Stp::CancelOldest: drop_maker = true; break;
            case Stp::CancelBoth:   drop_taker = drop_maker = true; break;
            case Stp::Decrement: {
                Qty q = std::min(taker.qty, maker.qty);
                taker.qty -= q;
                maker.qty -= q;
                std::println("    both reduced by {}", q);
                drop_taker = taker.qty == 0;
                drop_maker = maker.qty == 0;
                break;
            }
        }
        if (drop_maker) {
            std::println("    [{}] cancelled", maker.id);
            queue.pop_front();
        }
        if (drop_taker) {
            std::println("    [{}] cancelled", taker.id);
            taker.qty = 0;
        }
    }
    Stp mode_;
    std::map<Price, std::deque<Order>> asks_;
    std::unordered_map<OrderId, Account> owner_;
};

int main() {
    const char* names[] = {"cancel newest", "cancel oldest", "cancel both", "decrement"};
    using enum Stp;
    for (Stp mode : {CancelNewest, CancelOldest, CancelBoth, Decrement}) {
        std::println("{}:", names[int(mode)]);
        Engine e(mode);
        e.rest_sell({1, Side::Sell, 10002, 3}, 'A');
        e.rest_sell({2, Side::Sell, 10002, 2}, 'B');
        e.rest_sell({3, Side::Sell, 10003, 4}, 'A');
        e.buy({10, Side::Buy, 10003, 6}, 'A');  // A buys from a book holding A's sells
        e.print_asks();
    }
}
Output

Press Run to compile and run this example.

cancel newest:
  [10] meets own order [1]
    [10] cancelled
  asks left: [1] 3 @ 100.02  [2] 2 @ 100.02  [3] 4 @ 100.03
cancel oldest:
  [10] meets own order [1]
    [1] cancelled
  trade [10] x [2] 2 @ 100.02
  [10] meets own order [3]
    [3] cancelled
  [10] rests 4 @ 100.03
  asks left: none
cancel both:
  [10] meets own order [1]
    [1] cancelled
    [10] cancelled
  asks left: [2] 2 @ 100.02  [3] 4 @ 100.03
decrement:
  [10] meets own order [1]
    both reduced by 3
    [1] cancelled
  trade [10] x [2] 2 @ 100.02
  [10] meets own order [3]
    both reduced by 1
    [10] cancelled
  asks left: [3] 3 @ 100.03

Same book, same incoming order, four different outcomes, and in two of them the incoming order still trades with account B. That is why a venue publishes its STP behavior precisely and why participants choose the mode deliberately: "cancel newest" leaves the resting orders untouched, "cancel oldest" lets the newer instruction replace the older one, and "decrement" keeps as much of both as it can without printing a self-trade.

Pre-trade risk checks as a separate layer

Validation asks "is this order well-formed for this instrument?" Pre-trade risk checks ask a different question: "should this account be allowed to send it, given everything it has already done?" The answer depends on state that belongs to the account, not to the book, so it lives in a separate layer in front of the engine. The firm sending the orders runs such checks before orders leave its systems, and venues can run their own on arrival. In the EU, for example, Commission Delegated Regulation (EU) 2017/589 (known as RTS 6) requires investment firms doing algorithmic trading to have pre-trade controls including price collars, maximum order values, maximum order volumes and maximum message limits.

A few terms first. An account's position is the net quantity it holds: buys add to it, sells subtract, so a position of -20 means it has sold 20 lots more than it bought (a short position). The notional of an order is price times quantity. And an order that has been accepted but not yet filled is open.

Three checks are enough to show the design:

  • Position limit. The position must stay within ±50 lots. The check has to count open orders too: an account at +30 with 15 lots of open buys could reach +45 without sending anything else, so the next buy may add at most 5. Checking only the current position would let an account place any number of buys that all fill later.
  • Notional limit. No single order may exceed a maximum value, computed with the overflow check from the last lesson.
  • Message rate limit. At most 4 orders per account in any window of 10 time units. Time comes from the input events, never from a clock, so the test is reproducible.

The program sends a seeded random stream from three accounts through the risk layer and the engine, updates positions from the trades, and after every event checks that no account's position, and no account's worst case including open orders, has gone past the limit.

main.cppc++23
#include <algorithm>
#include <array>
#include <cstdint>
#include <deque>
#include <expected>
#include <functional>
#include <limits>
#include <map>
#include <print>
#include <unordered_map>
#include <vector>

using Price   = std::int64_t;   // integer ticks (a multiple of the tick size);
                                //   never a double
using Qty     = std::int64_t;   // whole lots
using OrderId = std::uint64_t;

enum class Side : std::uint8_t { Buy, Sell };

struct Order { OrderId id; Side side; Price price; Qty qty; };
struct Trade { OrderId taker; OrderId maker; Price price; Qty qty; };

struct Lcg {
    std::uint64_t s;
    std::uint32_t next() {
        s = s * 6364136223846793005ULL + 1442695040888963407ULL;
        return std::uint32_t(s >> 33);
    }
    int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
    double uniform() { return (next() + 0.5) / 2147483648.0; }  // in (0, 1)
};

class MatchingEngine {  // lesson 2's engine; cancel returns the quantity removed
public:
    std::vector<Trade> submit(Order o) {
        std::vector<Trade> out;
        if (o.side == Side::Buy)
            match(o, asks_, [&](Price p) { return p <= o.price; }, out);
        else
            match(o, bids_, [&](Price p) { return p >= o.price; }, out);
        if (o.qty > 0) {
            (o.side == Side::Buy ? bids_[o.price] : asks_[o.price]).push_back(o);
            where_[o.id] = o;
        }
        return out;
    }
    Qty cancel(OrderId id) {
        auto it = where_.find(id);
        if (it == where_.end()) return 0;
        Order o = it->second;
        where_.erase(it);
        return o.side == Side::Buy ? remove(bids_, o.price, id)
                                   : remove(asks_, o.price, id);
    }

private:
    template <class Levels, class Crosses>
    void match(Order& taker, Levels& book, Crosses crosses, std::vector<Trade>& out) {
        while (taker.qty > 0 && !book.empty() && crosses(book.begin()->first)) {
            auto level = book.begin();
            auto& queue = level->second;
            while (taker.qty > 0 && !queue.empty()) {
                Order& maker = queue.front();
                Qty q = std::min(taker.qty, maker.qty);
                out.push_back({taker.id, maker.id, level->first, q});
                taker.qty -= q;
                if ((maker.qty -= q) == 0) {
                    where_.erase(maker.id);
                    queue.pop_front();
                }
            }
            if (queue.empty()) book.erase(level);
        }
    }
    template <class Levels> static Qty remove(Levels& levels, Price price, OrderId id) {
        auto level = levels.find(price);
        auto it = std::ranges::find(level->second, id, &Order::id);
        Qty qty = it->qty;
        level->second.erase(it);
        if (level->second.empty()) levels.erase(level);
        return qty;
    }
    std::map<Price, std::deque<Order>, std::greater<>> bids_;
    std::map<Price, std::deque<Order>> asks_;
    std::unordered_map<OrderId, Order> where_;
};

enum class RiskReject : std::uint8_t { RateLimit, NotionalLimit, PositionLimit };

struct Limits {
    Qty max_position = 50;               // lots, long or short
    std::int64_t max_notional = 120'000; // 1,200.00 in units of 0.01
    int max_orders = 4;                  // per account ...
    int window = 10;                     // ... in any 10 time units
};

struct AccountState {
    Qty position = 0, open_buy = 0, open_sell = 0;
    std::deque<int> recent;  // times of recent orders, for the rate limit
};

std::expected<void, RiskReject> check(AccountState& a, const Order& o, int now,
                                      const Limits& lim) {
    while (!a.recent.empty() && a.recent.front() <= now - lim.window)
        a.recent.pop_front();
    if (int(a.recent.size()) >= lim.max_orders)
        return std::unexpected(RiskReject::RateLimit);
    a.recent.push_back(now);  // every order that reaches the limit check counts
    if (o.qty > std::numeric_limits<std::int64_t>::max() / o.price ||
        o.price * o.qty > lim.max_notional)
        return std::unexpected(RiskReject::NotionalLimit);
    bool buy = o.side == Side::Buy;
    Qty worst = buy ? a.position + a.open_buy + o.qty     // if every buy fills
                    : -(a.position - a.open_sell - o.qty); // if every sell fills
    if (worst > lim.max_position) return std::unexpected(RiskReject::PositionLimit);
    (buy ? a.open_buy : a.open_sell) += o.qty;
    return {};
}

int main() {
    Limits lim;
    MatchingEngine engine;
    std::array<AccountState, 3> accounts;
    std::unordered_map<OrderId, std::pair<int, Side>> owner;  // id -> account, side
    std::array<int, 3> rejects{};
    std::vector<OrderId> sent;
    Qty peak = 0;
    long checks = 0, violations = 0, accepted = 0;
    Lcg rng{99};
    int now = 0;
    for (int event = 0; event < 20'000; ++event) {
        now += rng.range(0, 3);
        int acct = rng.range(0, 2);
        AccountState& a = accounts[std::size_t(acct)];
        if (rng.range(0, 9) < 4 && !sent.empty()) {  // cancel one of the last 20 orders
            std::size_t back = rng.next() % std::min<std::size_t>(sent.size(), 20);
            OrderId id = sent[sent.size() - 1 - back];
            auto [who, side] = owner[id];
            Qty removed = engine.cancel(id);
            (side == Side::Buy ? accounts[std::size_t(who)].open_buy
                               : accounts[std::size_t(who)].open_sell) -= removed;
        } else {
            Order o{sent.size() + 1, rng.range(0, 1) ? Side::Buy : Side::Sell,
                    10000 + rng.range(-5, 5), rng.range(1, 15)};
            if (auto ok = check(a, o, now, lim); !ok) {
                ++rejects[std::size_t(ok.error())];
            } else {
                ++accepted;
                sent.push_back(o.id);
                owner[o.id] = {acct, o.side};
                for (const Trade& t : engine.submit(o)) {
                    for (OrderId id : {t.taker, t.maker}) {
                        auto [who, side] = owner[id];
                        AccountState& s = accounts[std::size_t(who)];
                        s.position += side == Side::Buy ? t.qty : -t.qty;
                        (side == Side::Buy ? s.open_buy : s.open_sell) -= t.qty;
                    }
                }
            }
        }
        for (const AccountState& s : accounts) {
            ++checks;
            peak = std::max(peak, s.position < 0 ? -s.position : s.position);
            violations += s.position + s.open_buy > lim.max_position ||
                          -(s.position - s.open_sell) > lim.max_position;
        }
    }
    std::println("orders accepted: {}", accepted);
    std::println("rejected: rate {}, notional {}, position {}", rejects[0], rejects[1],
                 rejects[2]);
    std::println("largest |position| seen: {} (limit {})", peak, lim.max_position);
    std::println("{} account checks, {} limit violations", checks, violations);
}
Output

Press Run to compile and run this example.

orders accepted: 5930
rejected: rate 292, notional 2643, position 3043
largest |position| seen: 49 (limit 50)
60000 account checks, 0 limit violations

The risk layer rejected orders for all three reasons, the largest position any account reached was 49 against a limit of 50, and the worst-case check held in all 60,000 account checks. The key line is the position check: it counts open quantity, reserved at acceptance and released by fills and cancels. Remove a.open_buy from that line and rerun: the worst-case check then fails tens of thousands of times and one account's position reaches 51, because several accepted buys can fill one after another and together pass the limit that each passed alone.

Keeping the risk layer separate from the engine pays off in several ways. The engine stays a pure matching component with one job. The risk layer can be tested on its own with recorded order streams. And the same checks can run in more than one place, at the firm and at the venue, without touching matching code.