OMS and Risk: Normalise, Check, Then Send (Kestrel Code Walkthrough)

What happens between an alpha saying "sell 60" and an order reaching a venue? In Kestrel, every order is normalised, risk-checked in a fixed order, and only then sent.

Intro

Between Intent and Exchange

An alpha decides it wants to sell sixty units. Somewhere between that decision and a venue, a dozen things can go wrong. The quantity may not fit a lot. The price may sit off the tick grid. A typo could be ten percent away from the market. Today we follow one order through that gauntlet.

  • Alpha intent
  • A dozen ways to go wrong
  • Normalise, check, then send

Where We Are in the Series

Last time, in episode seven, we saw the alpha framework, where alphas express their wishes as target positions behind gates. Those wishes become orders, and this episode is about the component that receives them: the order manager and its risk manager. Positions and profit and loss come next, in episode nine, so we will leave them alone.

  • Ep 7: alphas and targets
  • Ep 8: OMS and risk
  • Ep 9: positions, later

The Three Verbs

The title is the whole design. Normalise the order into something the market can accept. Check it against a fixed list of risk rules. Then, and only then, send it to the venue. Every order follows the same path in a backtest, in paper trading and in live trading, because the gateway owns this logic and only the venue underneath differs.

  • Normalise
  • Check
  • Send

Normalising the Order

Reject the Unknown First

Inside OrderManager submit, the first questions are about the instrument. If the instrument is unknown, or it has already expired, the order is rejected immediately. There is nothing sensible to round or price for a contract that does not exist, so we stop before doing any work.

  • Unknown instrument: reject
  • Expired instrument: reject

Quantity: Round Down to Lot

Next comes quantity. Derivatives trade in lots, so the requested quantity goes through roundQtyDown to a whole number of lots. Suppose the lot is twenty five and the alpha asks for seventy. That becomes fifty. Rounding down means we never trade more than was requested. If the result falls below the minimum quantity, the order is rejected.

  • roundQtyDown to lot
  • 70 with lot 25 gives 50
  • Below minQuantity: reject

Prices Never Get Worse

Prices follow one principle: rounding must never give a worse price than the one asked for. A buy limit rounds down. A sell limit rounds up. Take a tick of zero point zero five. A buy limit at one hundred point zero three becomes one hundred point zero zero. A sell limit at the same price becomes one hundred point zero five.

  • Buy limit: round DOWN
  • Sell limit: round UP
  • Never worse than asked

Stops Round the Other Way

Stop orders are the mirror image, because a stop is a trigger, not a limit. A buy stop rounds up, and a sell stop rounds down. Think of it as the stop firing on the safe side of what was asked. At the same tick, a buy stop at one hundred point zero three becomes one hundred point zero five.

  • Buy stop: round UP
  • Sell stop: round DOWN
  • Market: prices cleared

Market Orders and Tick Policy

A market order has no price of its own, so any price fields are cleared. There is also an optional tick size policy called reject. When enabled, instead of rounding an off-tick price, the manager refuses it outright. That suits setups where an off-grid price is more likely a bug than a harmless imprecision.

  • Market: clear prices
  • tickSize policy 'reject'
  • Off-tick price refused

The Normalisation Funnel

Put it together and you have a funnel. A raw request enters wide. Instrument checks, lot rounding, price rounding and the optional tick policy narrow it. What comes out is an order whose quantity and price the market could actually accept, or an early rejection with a reason.

  • Raw request in
  • Four narrowing steps
  • Clean order or reason out

The Risk Input

Building RiskCheckInput

Now the order must be judged. The manager gathers a RiskCheckInput: the last price, the strategy's own position, the net position, same-side pending quantity, and gross exposure. Optionally it adds pending values and margin. Risk then judges a snapshot, rather than reaching into the order manager itself.

  • Last price
  • Strategy and net position
  • Same-side pending, gross exposure
  • Optional pending values, margin

Net of Working Reducing Orders

The subtle field is strategyPosition. It is not the raw position. It is the position net of the strategy's own working reducing orders. If the strategy holds a positive amount, we subtract the pending reducing quantity, floored at zero. If it holds a negative amount, we add it, capped at zero.

  • Held minus pending reducing
  • Floored at zero when long
  • Mirrored when short

The Code: Netting

Here is the line, shown as in the source. Held is the position. If held is positive, strategyPosition is the larger of zero and held minus pendingReducing. Otherwise it is the smaller of zero and held plus pendingReducing. The clamp to zero is what stops this number from ever crossing sign.

  • Clamp prevents sign flip

Why Net It?

Why bother? Because stacked reducing orders can otherwise add up to more than the position. Each one looks harmless alone. Together they could flip a long position into a short one. By counting earlier working exits against the position, each new order sees only what is still left to reduce. We will run exact numbers shortly.

  • Each exit looks harmless alone
  • Together they can flip the sign
  • Netting shows what is left

The Ordered Risk Checks

First Failure Wins

RiskManager check runs a fixed list of rules in a fixed order, and returns the first failure. That makes rejection reasons deterministic. Two different bad orders always produce the same message, run after run, which matters when you compare backtests and read logs.

  • Fixed order
  • First failure returned
  • Deterministic reasons

Gate One to Four

The first gates are these. Is the instrument known and unexpired? Is the kill switch engaged? That one applies only to non-reducing orders. Is the strategy locked by mark to market? Then a reference price is needed: the limit or stop price, or else the last price. With none available, the reason is no market data.

  • Instrument known, unexpired
  • Kill switch (non-reducing only)
  • MTM-locked strategy
  • Reference price or 'no market data'

The Price Collar

Next is the price collar. For limit and stop orders, the absolute value of reference over last, minus one, must be at most the collar percentage. The default is ten percent. It is a fat-finger guard: a stray zero or a wrong digit should not reach the exchange.

  • |ref / last - 1| <= priceCollarPct
  • Default 0.10
  • Limit and stop orders

Worked Example: The Collar

Take a last price of two thousand and a limit order at two thousand two hundred fifty. Divide: one point one two five. Subtract one: zero point one two five. That is twelve and a half percent, greater than ten. The order is rejected by the collar, before any cap is even looked at.

  • Last 2000, limit 2250
  • 2250 / 2000 - 1 = 0.125
  • 12.5% > 10%: reject

Bands, Bans and Notional

After the collar come the price band and the F and O ban check. Then the order notional: quantity times reference price times multiplier must not exceed maxOrderNotional, which applies to non-reducing orders only. The default is two million rupees. One order cannot be enormous.

  • Price band, F&O ban
  • qty x ref x mult <= maxOrderNotional
  • Default Rs 20,00,000

When Net Exposure Grows

If the order would grow net exposure, three more caps apply: maxPositionNotional, five million by default, maxPositionQuantity, and maxGrossExposure, twenty million by default. An order that only shrinks exposure skips these, since it makes the book safer rather than riskier.

  • maxPositionNotional: 5,000,000
  • maxPositionQuantity
  • maxGrossExposure: 20,000,000

The Final Caps

Last come the optional caps: open quantity, day value, a SPAN-like margin, the order to trade ratio, and orders per second. The rate window is the half open interval from now minus one second up to and including now. The default is fifty orders per second.

  • Open qty, day value, margin
  • Order-to-trade ratio
  • Orders/sec in (now-1s, now]
  • Default 50

Event Time, Not Wall Time

Notice that the rate limit uses the timestamp of the event, not the wall clock. All times in risk are event time. A backtest that replays a day in seconds sees the same one second windows as a live session would, so the same input history always yields the same decisions.

  • All risk times are event time
  • Replay speed is irrelevant
  • Same input, same decisions

Reducing Orders

What Counts as Reducing

An order is reducing when the position has the opposite sign to the order, and the order's absolute quantity is at most the absolute strategy position. In plain terms, it only closes some or all of what you hold, and does not push through zero into the other side.

  • Position sign opposite to order
  • |q| <= |strategyPosition|
  • Closes, never flips

The Bypass

Reducing orders bypass three things: the kill switch, the notional cap and the rate limit. The reason is simple. When something has gone wrong, the most important thing is to get out. An exit stranded behind a safety rule would turn a safeguard into the problem.

  • Bypass: kill switch
  • Bypass: notional cap
  • Bypass: rate limit

Still Needs Market Data and Collar

But a reducing order still needs market data and still faces the collar. As the design puts it, a reducing limit far from the market is still a fat-finger. Selling a long position at a price off by a factor of ten is closing, yes, but it is still a mistake worth catching.

  • Still needs a reference price
  • Still faces the collar
  • Far reducing limit is a fat-finger

Worked Example: Stacked Sells

Now the example. The strategy is long one hundred, with the kill switch on. A first sell of sixty arrives. Pending reducing is zero, so strategyPosition is one hundred. Sixty is at most one hundred, so it is reducing, and it passes. The second sell of sixty then arrives.

  • Long 100, kill switch on
  • Sell 60 #1: sees 100, reducing, passes
  • Sell 60 #2 arrives

Second Sell Is Blocked

Now pending reducing is sixty, so strategyPosition becomes forty. The order's absolute quantity, sixty, is greater than forty. It is not reducing, so the kill switch applies and the order is blocked. Without the netting, both sells would pass and the position would end at short twenty.

  • Pending 60: strategyPosition 40
  • |60| > 40: not reducing
  • Blocked by kill switch

The Kill Switch

Daily Loss and onEquity

What engages the kill switch? The daily loss rule. On each equity update, onEquity checks whether the day has rolled over. On each new UTC day it resets dayStart to the current equity. Then if equity minus dayStart falls below minus maxDailyLoss, the switch engages.

  • New UTC day: reset dayStart
  • equity - dayStart < -maxDailyLoss
  • Default maxDailyLoss 100,000

Only the First Transition

Here is a detail. onEquity returns true only on the false to true transition. Say dayStart is one million and equity drops to eight hundred ninety nine thousand. The loss is a hundred and one thousand, past the limit, so it returns true once. The core then cancels everything. Later updates return false.

  • Returns true once
  • Core cancels all working orders
  • Later updates return false

The Order Lifecycle

Accepting the Order

If every check passes, the order is accepted. Here is the sequence. It gets an id from nextId. Its status becomes PendingNew. If it is not reducing, the order is recorded for the rate limit. Risk is told about the acceptance. The book adds the order as open, and the venue receives it.

  • id = nextId_++
  • Status PendingNew
  • recordOrder if not reducing
  • addOpen, then venue submit

Rejected Orders Stay Quiet

A rejected order is treated differently. It still receives an id and is stored in the closed list, so you can inspect it later. But it is not reported through the listeners. The alpha learns the outcome from the SubmitResult it gets back, not from an event stream.

  • Rejected gets an id
  • Stored in closed list
  • Not sent to listeners
  • Alpha reads SubmitResult

The State Machine

Once accepted, an order lives in the OrderBook as a forward only state machine. PendingNew is rank zero. New is rank one. PartiallyFilled is rank two. Filled, Cancelled and Rejected are rank three, and terminal. Status can only move to a higher rank, never back.

  • PendingNew 0, New 1
  • PartiallyFilled 2
  • Filled, Cancelled, Rejected 3
  • Forward only

Ignoring Stale Updates

Updates can arrive late or out of order. So onOrderUpdate returns early if the order is already terminal, or if the update's status rank is lower than the order's current rank. The filled quantity also never decreases. A late New arriving after Filled simply bounces off.

  • isTerminal: ignore
  • Lower rank: ignore
  • filledQuantity never decreases

Closed List and Updates Only

When an order reaches a terminal state it moves from the open set to the closed list, which is capped at two thousand entries. And note the rule: status, filled quantity and average price change only from an OrderUpdate, never from a Fill. Fills are a separate stream.

  • Terminal moves to closed list
  • Capped at 2000
  • Only OrderUpdate changes status

Pitfalls and Recap

Pitfall: Fills Changing Status

The first pitfall is letting fills change order status. Fills and updates can arrive in either order. If a fill promoted the status, a later update could contradict it, and the book would depend on arrival order. One source of truth, the OrderUpdate, keeps the machine consistent.

  • Two streams, either order
  • One source of truth
  • Avoids arrival-order bugs

Pitfall: Wall Time in Risk

The second pitfall is wall time in risk. If the rate limit or day rollover used the system clock, a fast replay would trip limits that real time never would, and results would differ between runs. Using event time keeps every backtest reproducible, and the rate window matches what the data says.

  • Wall clock breaks replays
  • Event time is reproducible
  • Same history, same rejections

Recap and Next Time

To recap: normalise so prices are never worse than asked, check in a fixed order and return the first failure, then send. Reducing orders bypass the kill switch, notional cap and rate limit, but not the collar. Status moves forward only. Next, episode nine: how fills turn into positions, cash and profit and loss.

  • Normalise, check, send
  • Reducing bypass, collar stays
  • Forward-only status
  • Next: positions and P&L