AlgoDaily

Pre-Trade Risk Checks

Read at your own pace. Code execution, quizzes, progress tracking, and the AI tutor are available in the interactive view with JavaScript enabled.

Orders Need Gates

Why this lesson matters

In Order Management & FIX Integration, you saw that a strategy does not send directly to the exchange. It asks the OMS to create, track, and encode orders.

Now we zoom into one OMS responsibility: pre-trade risk checks.

A strategy may generate a bad order because of:

  • a bug in signal logic
  • stale or missing reference data
  • a bad config value
  • a loop that sends too many orders
  • an unexpected position or open-order state

The risk gate’s job is simple:

Strategy intent -> Pre-trade risk -> OMS state -> FIX Gateway -> Exchange
                     reject here if unsafe

A fast trading system is only useful if it refuses dangerous orders before they leave your process.

Orders Need Gates

Risk Gate Map

What gets checked?

A practical pre-trade risk layer combines several small checks. Each one answers a narrow question.

OrderIntent
  |
  +-> Symbol allowed?
  +-> Side and quantity valid?
  +-> Price sane?
  +-> Notional below limit?
  +-> Position after order below limit?
  +-> Open-order exposure below limit?
  +-> Order rate below limit?
  +-> Kill switch enabled?

Two habits matter:

  1. Fail closed: if required information is missing, reject the order.
  2. Return a reason: every rejection should be explainable later.

This is not only a trading concern. It is also a debugging concern. When a strategy stops trading, you want to know whether it stopped because there were no signals or because the risk gate rejected everything.

Risk Gate Map

Check Location

Quick check

A strategy produces an order intent: BUY 10,000 XYZ @ 250.00.

Where should pre-trade risk checks generally run?

Basic Risk Engine

A first C++ risk gate

Start with a clear reference implementation. This is not the final low-latency layout, but it makes the rules obvious.

We will check:

  • symbol is allowed
  • quantity is positive and below max
  • price is positive
  • order notional is below max
  • price is within a band around a reference price
  • kill switch is not active

Notice that the function returns a structured result instead of just true or false. That reason string is valuable when debugging OMS behavior.

```cpp
#include <cmath>
#include <iostream>
#include <string>
#include <unordered_set>

struct OrderIntent {
    std::string symbol;
    char side;       // 'B' buy, 'S' sell
    int quantity;
    double price;
};

struct RiskConfig {
    std::unordered_set<std::string> allowedSymbols{"AAPL", "MSFT"};
    int maxQty = 1000;
    double maxNotional = 100000.0;
    double maxPriceMovePct = 0.05; // 5% away from reference
    bool killSwitch = false;
};

struct RiskResult {
    bool accepted;
    std::string reason;
};

RiskResult checkOrder(const OrderIntent& o, const RiskConfig& cfg, double referencePrice) {
    if (cfg.killSwitch) {
        return {false, "kill switch active"};
    }
    if (!cfg.allowedSymbols.contains(o.symbol)) {
        return {false, "symbol not allowed"};
    }
    if (o.side != 'B' && o.side != 'S') {
        return {false, "invalid side"};
    }
    if (o.quantity <= 0 || o.quantity > cfg.maxQty) {
        return {false, "quantity outside limit"};
    }
    if (o.price <= 0.0 || referencePrice <= 0.0) {
        return {false, "invalid price or reference"};
    }

    const double notional = o.quantity * o.price;
    if (notional > cfg.maxNotional) {
        return {false, "notional limit exceeded"};
    }

    const double movePct = std::fabs(o.price - referencePrice) / referencePrice;
    if (movePct > cfg.maxPriceMovePct) {
        return {false, "price band exceeded"};
    }

    return {true, "accepted"};
}

int main() {
    RiskConfig cfg;

    OrderIntent orders[] = {
        {"AAPL", 'B', 100, 190.00},
        {"TSLA", 'B', 100, 250.00},
        {"MSFT", 'S', 2000, 410.00},
        {"AAPL", 'B', 100, 250.00}
    };

    const double referenceAapl = 188.00;

    for (const auto& o : orders) {
        RiskResult r = checkOrder(o, cfg, referenceAapl);
        std::cout << o.symbol << " " << o.side << " " << o.quantity
                  << " @ " << o.price << " -> "
                  << (r.accepted ? "ACCEPT" : "REJECT")
                  << " (" << r.reason << ")\n";
    }
}
```

Fail Closed

True or false?

If the risk layer is missing required reference data, it is safer to accept the order and let the exchange decide.

Position Limits

Risk is not only one order

Checking a single order is necessary, but not enough.

Suppose your current position is +900 shares and your max long position is +1000.

Current position:       +900
New buy order:          +200
Projected position:    +1100  -> reject

For sells, the projection moves the other way:

Current position:       -850
New sell order:         -300
Projected position:    -1150  -> reject if max short is -1000

In production, you also account for open orders that have been sent but not filled or canceled yet. Otherwise, ten accepted buy orders of 100 shares can quietly become 1000 shares of pending exposure.

We will keep the model small here: current position plus open buy/sell quantity plus the new order.

Position Limits

Projected Exposure Code

Check the position after the order

This example tracks a small position state for one symbol.

The key idea:

projected = current_position + open_buy_qty - open_sell_qty + signed_new_order_qty

Why include open orders? Because they can still fill. If you ignore them, the risk gate may accept too many orders before fills arrive.

```cpp
#include <iostream>
#include <string>

struct OrderIntent {
    std::string symbol;
    char side;       // 'B' buy, 'S' sell
    int quantity;
};

struct PositionState {
    int currentPosition = 0;
    int openBuyQty = 0;
    int openSellQty = 0;
};

struct PositionLimits {
    int maxLong = 1000;
    int maxShort = -1000;
};

int signedQty(const OrderIntent& o) {
    return o.side == 'B' ? o.quantity : -o.quantity;
}

int projectedPosition(const PositionState& s, const OrderIntent& o) {
    return s.currentPosition + s.openBuyQty - s.openSellQty + signedQty(o);
}

bool withinLimits(int projected, const PositionLimits& limits) {
    return projected <= limits.maxLong && projected >= limits.maxShort;
}

void test(const PositionState& state, const OrderIntent& order, const PositionLimits& limits) {
    const int projected = projectedPosition(state, order);
    std::cout << order.symbol << " " << order.side << " " << order.quantity
              << " projected=" << projected << " -> "
              << (withinLimits(projected, limits) ? "ACCEPT" : "REJECT") << "\n";
}

int main() {
    PositionLimits limits{1000, -1000};

    PositionState nearLong;
    nearLong.currentPosition = 900;
    nearLong.openBuyQty = 50;
    nearLong.openSellQty = 0;

    test(nearLong, {"AAPL", 'B', 25}, limits);   // projected 975
    test(nearLong, {"AAPL", 'B', 100}, limits);  // projected 1050
    test(nearLong, {"AAPL", 'S', 300}, limits);  // projected 650
}
```

Missing Number

Fill in the blank

For a buy order, projected position generally moves upward by the order ________.

Rate And Kill Switch

Stop runaway behavior

Some failures are not about one bad price or one large quantity. They are about too many acceptable-looking orders.

Examples:

  • a strategy loop emits the same order repeatedly
  • network reconnect logic resends too aggressively
  • cancel/replace logic starts thrashing
  • a config error enables a strategy on too many symbols

Two common controls:

  1. Rate limit: allow only N orders per time window.
  2. Kill switch: immediately reject new orders when risk, ops, or monitoring disables trading.

A rate limit is not a substitute for price and position checks. It is another layer. In HFT systems, safety usually comes from multiple simple gates rather than one magical rule.

```cpp
#include <iostream>

class SimpleOrderRateLimiter {
    int maxPerWindow;
    int usedInWindow = 0;

public:
    explicit SimpleOrderRateLimiter(int maxOrders) : maxPerWindow(maxOrders) {}

    bool allow() {
        if (usedInWindow >= maxPerWindow) {
            return false;
        }
        ++usedInWindow;
        return true;
    }

    void resetWindow() {
        usedInWindow = 0;
    }
};

int main() {
    SimpleOrderRateLimiter limiter(3);

    for (int i = 1; i <= 5; ++i) {
        std::cout << "order " << i << " -> "
                  << (limiter.allow() ? "ACCEPT" : "REJECT rate limit")
                  << "\n";
    }

    std::cout << "reset window\n";
    limiter.resetWindow();

    std::cout << "order 6 -> "
              << (limiter.allow() ? "ACCEPT" : "REJECT rate limit")
              << "\n";
}
```

Audit The Rejection

Rejections are useful data

A risk rejection should not disappear silently.

At minimum, record:

  • timestamp
  • strategy or component name
  • symbol
  • side, quantity, price
  • reject reason
  • relevant limits
  • current position or exposure snapshot
09:30:01.123 risk_reject strategy=twap_1 symbol=AAPL side=B qty=2000
reason=quantity_outside_limit limit=1000 current_pos=350

This helps answer practical questions:

  • Did the strategy stop because signals stopped?
  • Did a config limit block all orders?
  • Did a price reference become stale?
  • Did position exposure grow faster than expected?

In Latency Instrumentation and Histograms, you learned not to print inside hot paths casually. The same applies here: design audit logging carefully. In a lab, std::cout is fine. In production, use a measured, non-blocking path.

Hot Path Shape

Keep checks predictable

Pre-trade risk sits on the outbound order path, so it must be correct and predictable.

Good habits from Low-Latency C++ for HFT still apply:

  • load config before trading starts
  • avoid allocation in the hot check function
  • prefer simple numeric comparisons
  • keep reject reasons stable and enumerable
  • separate slow logging from the decision path
  • test boundary cases: exactly at max qty, just above max qty, missing reference price

A common production shape is:

Startup:
  load limits, symbols, account config
  validate config
  arm strategy only if config is sane

Hot path:
  read immutable limits
  read current exposure
  run simple checks
  accept or reject with reason code

Next, Market Data Handling & Order Book Reconstruction will build the trustworthy market state that later checks and strategies can consume.

Hot Path Shape

Risk Gate Checklist

Final check

Which design is strongest for a beginner-friendly pre-trade risk layer?

One Pager Cheat Sheet

One Pager Cheat Sheet

  • Pre-trade risk checks reject unsafe order intent before the OMS sends anything outward.
  • Risk gates should be small, explicit, fail-closed checks that return clear reject reasons.
  • Pre-trade risk belongs before outbound order encoding and transmission.
  • A risk check should return both accept/reject and a durable reason for audit and debugging.
  • When required risk inputs are missing, fail closed rather than sending unknown risk.
  • Position checks must consider projected exposure, not just the latest order in isolation.
  • Projected exposure should include current position, live open orders, and the new order.
  • Buy orders add quantity to projected position; sell orders subtract quantity.
  • Rate limits and kill switches protect against runaway order flow, not just individual bad orders.
  • Every risk rejection should be auditable with reason, order details, and relevant state.
  • Pre-trade checks should be preconfigured, allocation-light, deterministic, and tested at boundaries.
  • A strong risk layer is explicit, fail-closed, and explainable with stable reject reasons.
Open interactive view →