Capital Markets and Settlement
Where does a fat-finger order actually get stopped?
Goal
You add to an order stream, one layer at a time, the single-order limit, price band, restricted symbols, duplicates, cumulative exposure, and kill switch, and confirm with numbers how the timing of updating the cumulative and the order of checks change the result.
Why it matters
Finding something after it executes and stopping it before it goes out are different jobs. An order that has gone out is already in the market, and the cost of undoing it is a loss, plain and simple. So checks are placed on the order path. But a check does not work just by being switched on. If you put even rejected orders into the cumulative limit, a person whose real exposure is half the limit gets blocked, and if you let a symbol with no reference price pass, the reason for having the check disappears. If you count a hold as a reject, the kill switch engages in the wrong place, and if you keep the block state only in memory, one restart releases it. This lab has you build each of those traps yourself and confirm them with numbers.
Steps
- Use
python3to createorders.jsonl,limits.json,limits_tight.json,refprice.csv,restricted.txt, andaccounts.csvin/root/risk/data. - Apply only the single-order limits (quantity and amount) and create
/root/risk/single.csv. - Add the price band check and create
/root/risk/band.csv. A symbol with no reference price is a hold. - Add the restricted-symbol and duplicate-order checks and create
/root/risk/screen.csv. - Add the account and desk cumulative exposure limits to create
/root/risk/exposure.csv, and write the orders that a wrong implementation that puts even rejected orders into the cumulative would have blocked to/root/risk/naive_blocked.csv. - Add the kill switch to create
/root/risk/final.csvand leave the block state in/root/risk/killswitch.json. - Run again with the price band check moved earlier in the order, and write the differences to
/root/risk/order_compare.csvand/root/risk/order_compare.json. - Write statistics by reason to
/root/risk/stats.json, and the comparison with the tighter-limit configuration to/root/risk/tuning.json.
Notes
- There are four decision values.
ACCEPT(pass),REJECT(reject),HOLD(hold), andBLOCK(full block by the kill switch). There are ten reasons:OK,MAX_QTY,MAX_NOTIONAL,PRICE_BAND,NO_REF_PRICE,RESTRICTED,DUPLICATE,ACCT_EXPOSURE,DESK_EXPOSURE,KILL_SWITCH. - The standard check order is
RESTRICTED, thenMAX_QTY,MAX_NOTIONAL,PRICE_BAND,DUPLICATE,ACCT_EXPOSURE,DESK_EXPOSURE. Write the reason of the first check that trips and stop there. - Each step applies only the checks switched on up to that point. Step 2 uses only the quantity and amount limits, and step 3 adds the price band to that. Write every order, one per line (including the ones that passed).
- Compute amounts with
decimaland write them to the second decimal place. Compare the price band as a percentage of the reference price, but if you compare by multiplying both sides instead of dividing, no rounding problem arises. - Cumulative exposure adds only the orders that passed. Rejects and holds do not consume the cumulative.
- The criterion for duplicates is that account, symbol, side, quantity, and limit price are all the same, and the time gap from an order that passed earlier is
duplicate_window_secor less. - The kill switch engages the moment
rejectsor more rejects have accumulated withinwindow_sec. Holds and blocks are not counted as rejects. The order that triggers it keeps its original decision, and every order from the next one on isBLOCK. - Do not edit the data files. The grader checks the data fingerprint at every step.
- Common mistake 1: letting a symbol with no reference price pass.
- Common mistake 2: summing cumulative exposure straight from the order log, including rejected orders.
- Public documents: eCFR 240.15c3-5, Federal Register adopting release, FIX Standards, decimal. The limit values and the 47 orders are synthetic data for this lab.
Generate the order stream and the limit definitions
Use python3 to create orders.jsonl, limits.json, limits_tight.json, refprice.csv, restricted.txt, and accounts.csv in /root/risk/data. Use the generation script as is, which uses no random numbers.
We cannot bring in the customer's order log as it is, so we build synthetic data of the same shape. Without random numbers, the same data comes out no matter who runs it how many times, and you can compare each other's judgments. Times are fixed in the data too — if you build them from today's date, the judgments change when you run it tomorrow. The grader converts the data to a canonical form and compares fingerprints, so if you edit it by hand, all the later steps get blocked.
Apply the single-order limits
Put the first line order_id,decision,reason,notional in /root/risk/single.csv and apply only the quantity upper bound and the amount upper bound, writing all 47 orders, one per line.
The amount is quantity times limit price. If you bound only the quantity, it leaks through on expensive symbols, so look at both. In the standard order, quantity comes before amount, so for an order that exceeds both, the reason is the former. The reason for an order that passed is OK, and write amounts to the second decimal place.
The price band and symbols with no reference price
Put the first line order_id,decision,reason in /root/risk/band.csv and write all 47 orders, adding the price band check to the two checks from the previous step. A symbol with no reference price is HOLD and NO_REF_PRICE.
Block limit prices that stray from the reference price by more than the allowed percentage. If you use division when comparing, rounding gets involved, so multiply the difference by 100 and compare it with the allowed percentage times the reference price. If you let a symbol with no reference price pass, the reason for having the check disappears. Make a place that is neither pass nor reject.
Screen out restricted symbols and duplicate orders
Put the first line order_id,decision,reason in /root/risk/screen.csv and write all 47 orders, adding the restricted-symbol check and the duplicate-order check to the previous step.
A restricted symbol is a list match and the cheapest, so it comes first in the standard order. A duplicate is an order with the same account, symbol, side, quantity, and limit price that came in within the window, but you compare only against orders that passed earlier. If you use a rejected order as the reference, a person who made one mistake gets their second, normal order blocked too.
Cumulative exposure limits and comparing with the wrong implementation
Write the decisions with the account and desk cumulative limits applied to /root/risk/exposure.csv as order_id,decision,reason, and in /root/risk/naive_blocked.csv put the first line order_id,account,desk,reason_naive and write the orders that an implementation that puts even rejected orders into the cumulative would have blocked.
Count the cumulative separately per account and per desk. Add only the amounts of orders that passed; rejects and holds do not consume the cumulative. Then deliberately build one more wrong implementation — scan the order log as is and add the amounts of all orders. The orders for which the correct side gives pass but the wrong side gives not-pass are the unjustly blocked orders.
Engage the kill switch and leave the state in a file
Write the decisions with the kill switch applied to /root/risk/final.csv as order_id,decision,reason, and write engaged, engaged_at, trigger_order_id, rejects_in_window, threshold, window_sec, and blocked_orders to /root/risk/killswitch.json.
Individual rejection and blocking everything are different actions. When rejects pile up within the window to the threshold, from then on you close the path entirely. Holds and blocks are not counted as rejects. The order that caused it to engage keeps its original decision, and every order from the next one on is blocked. And always leave the block state in a file — if you keep it only in memory, one restart releases it.
Run again with the check order changed by one position
Run again with the price band check moved in front of the quantity upper bound, and in /root/risk/order_compare.csv write only the orders that changed, as order_id,decision_canonical,reason_canonical,decision_band_first,reason_band_first, and in /root/risk/order_compare.json write a summary of the two orders.
You might think only the reason would change, but it does not. A symbol with no reference price becomes a hold, and a hold is not counted as a reject, so when the band check comes first, one reject becomes a hold and the point at which the kill switch engages is pushed back. Orders that come in during that time go out without being blocked. In the summary put accept_canonical, accept_band_first, reject_canonical, reject_band_first, hold_canonical, hold_band_first, blocked_canonical, blocked_band_first, kill_trigger_canonical, kill_trigger_band_first, and diff_orders.
Statistics by reason and comparison with the tighter-limit configuration
Write orders, accept, reject, hold, blocked, and by_reason to /root/risk/stats.json, and base_accept, tight_accept, newly_rejected_count, newly_rejected, and tight_by_reason to /root/risk/tuning.json. newly_rejected is the list of orders that passed under the current configuration but do not pass under the tighter configuration.
Produce the statistics from the step 6 decisions, that is, the result with the kill switch engaged. Then run the same day once more with limits_tight.json. The difference between the two configurations shows not only in the counts by reason but in the number of passes itself. Write newly_rejected in ascending order number, and confirm from the output why tightening the limits also affects the kill switch.