Risk model¶
The risk layer decides whether each new order and replace the engine is about to send may go. The code is include/fastmm/core/risk.hpp; the limits are the [risk] section of the Configuration.
Where the checks sit¶
Every new order and every replace passes through RiskEngine on the engine thread, after the strategy decided and before the OMS records the order: quotes from the quote manager and direct orders from ctx.send alike. A refused order is never sent. The strategy sees the RejectReason in the Result of ctx.send, or a quote that does not appear; the engine counts rejects per reason (fastmm-top, the shutdown summary) and logs the first reject of each reason, then at most one line per reason every 10 s.
Cancels skip the checks, also after the kill switch has tripped, so a strategy or the engine can always reduce what is in the market.
The checks are integer compares on state the engine already keeps; price bounds are recomputed on every book and trade update. BM_Risk_CheckNew_Pass, an order that passes all checks, takes 6.5 ns (bench/README.md).
The checks, in order¶
The first failing check decides the reason.
| # | Reason | Refuses an order when | Setting |
|---|---|---|---|
| 1 | KillSwitch |
the global kill switch is set | |
| 2 | VenueKilled |
the venue's kill bit is set | |
| 3 | InstrumentDisabled |
the instrument is disabled or out of range | [[instruments]] enabled |
| 4 | InvalidTick |
a limit price is not a multiple of the tick | tick |
| 5 | InvalidLot |
the quantity is not a multiple of the lot or outside min_qty/max_qty |
lot, min_qty, max_qty |
| 6 | BelowMinNotional |
the order notional (the mid for market orders) is below the minimum | min_notional |
| 7 | StaleMarketData |
the instrument's book is older than the limit, or there is none | stale_md_ms |
| 8 | FeedLag |
the venue's feed-lag gate holds, the order could rest (not IOC, FOK or market) and it does not only reduce the position (Feed-lag gate) | max_feed_lag_ms |
| 9 | PriceCollar |
a limit price is further from the mid than the collar | price_collar_bps |
| 10 | FatFinger |
a limit price is further from the last trade than the band | fat_finger_bps |
| 11 | MaxOrderQty |
the quantity exceeds the limit | max_order_qty |
| 12 | MaxOrderNotional |
the order value exceeds the limit | max_order_notional |
| 13 | MaxPosition |
position plus same-side open orders plus this order would exceed the limit in absolute value and increase exposure | max_position |
| 14 | FxRateUnknown |
the order adds to exposure in a settlement currency whose rate is unknown or stale, while a limit reads the totals | [accounting] |
| 15 | MaxGrossNotional |
the portfolio's summed |position| at the last marks would pass the cap, and this order adds to it | max_gross_notional |
| 16 | MaxNetNotional |
the portfolio's signed position sum would move further past the cap | max_net_notional |
| 17 | UnderlyingMarkUnknown |
an inverse contract of the order's underlying has a position or open orders, or is the order's instrument, and no current mark | [risk.underlying] |
| 18 | MaxUnderlyingNet |
the net position in the order's underlying, with the open orders on the order's side and this order, would move further past the limit | [risk.underlying.<BASE>] max_net |
| 19 | MaxOpenOrders |
the instrument already has this many open orders (new orders only) | max_open_orders |
| 20 | SelfTradePrevention |
a limit price would trade against one of our own resting orders | stp |
| 21 | BalanceShort |
the account's balance on the venue does not cover the order (Balance check) | check_balance |
| 22 | VenueKilled |
a pool member's order and the pool's primary venue is killed (account pools) | |
| 23 | RateLimit |
a pool order's account cannot send it now: its connector is paused or an order window is at its cap (ctx.order_budget) |
|
| 24 | RateLimit |
the token bucket is empty | orders_per_sec, burst |
- A limit of 0 turns its check off; the checks against the instrument's reference data always run.
- A replace excludes the existing order's remaining quantity from the position prediction and is not counted against
max_open_orders. - Only an order that passes every check takes a token from the bucket: one refused for any other reason, a full or paused pool account included, leaves the tokens to the orders that are sent.
MaxPositioncounts same-side open orders, so a quote ladder cannot exceedmax_positioneven if it fills entirely.- Market orders skip the price checks (4, 9, 10, 20).
- The notional of checks 6 and 12 is in the instrument's settlement currency:
price * qty * multiplierfor a linear contract,qty * multiplier / price(the base coin) for an inverse one. - Checks 15 and 16 compare in the reporting currency when
[accounting]converts (Currencies): the order's notional at its currency's rate, on top of the converted totals.
Feed-lag gate¶
The stale-market-data check (7) measures the time since the book last changed. During venue congestion messages keep arriving, each tens of milliseconds old, so it never fires. The feed-lag gate measures the age of each message instead (include/fastmm/core/venue_health.hpp):
- Every market-data message with a venue time (book deltas, trades, tickers; snapshots excluded) gives a lag
recv_ts - exch_ts. The host and venue clocks differ by an unknown constant, so the lag is compared with its baseline, the minimum over the last 8 s (eight 1 s buckets on the engine clock). A host clock step moves every lag by the step: a step down is absorbed at once, a step up after at most 8 s. A congestion episode shorter than 8 s does not raise the baseline. - With
[risk] max_feed_lag_msset, a message whose lag exceeds the baseline by more than the limit engages the gate for its venue, and every such message holds it for 100 ms more. While it holds, the venue's quotes are pulled,set_quoteson its instruments returns false and new orders and replaces that could rest are refused (FeedLag, check 8). IOC, FOK and market orders pass, and so does an order that, with our open orders on its side, takes the position towards zero without crossing it. The first message 100 ms after the last late one releases it; the strategy's nextset_quotesplaces the quotes again. - The inputs are the journaled message headers and the engine clock, so a replay gates at the same events. A backtest over a live journal gates only with
[backtest] md_arrival = "recorded", which delivers each message at its recordedrecv_ts. - Venue times of the WebSocket JSON streams are in milliseconds, so the lag has 1 ms steps there; SBE streams carry microseconds.
Balance check¶
Connectors report the account's balances as BalanceMsg events on the order ring: a snapshot after every reconciliation (and at start-up), and the private stream's updates where the venue has one (Venues); in a backtest the simulated venue sends them (Backtesting). The engine keeps one row per venue and asset: the base and quote of each spot instrument, the settlement asset of each derivative, and an account row for a venue that reports account-wide margin (include/fastmm/core/balance_book.hpp). The events are journaled; a replay rebuilds the same rows.
A report is the account as of its venue time and counts the orders the venue had acknowledged. An order still waiting for its ack keeps its hold on top of the report until the ack says whether the report had it (stamped at or before the report's time). Until the next report the engine moves the row with its own events, except those the venue stamped at or before the report's time (the report has them); the events of an order the venue has not acknowledged always count:
- An order holds part of a row, moving it from free to locked: a spot buy its notional plus the taker fee (
fees(id)) in the quote asset, a spot sell its quantity in the base asset, a derivative its notional times[[instruments]] initial_marginin its margin row, the larger of its instrument's buy and sell sides. Reduce-only derivative orders hold nothing. An order's end releases its hold; a fill releases the filled part and an amend changes it. - A fill moves the assets: a spot buy adds the base (less a base-asset fee) and takes the notional (and a quote-asset fee) from the quote, a sell the reverse. A derivative fill moves the position's initial margin at the fill price into locked and takes a fee in the settlement asset.
The estimate counts twice an event that reaches the engine after a report that already had it and carries no venue time, and the hold of an order whose ack carries none, until the next report. Push venues send an update after every order change. So the connectors stamp the acks and cancel acks they take from order responses with a venue time from the response that is no earlier than the order's effect on the balance: Binance Spot transactTime, Binance USDⓈ-M updateTime, OKX the order's ts, Bybit the Timenow header (REST: time), Deribit usOut, Coinbase Exchange created_at. A report that arrives before the ack then does not leave the order held twice; a later stamp than the report's at worst does, an earlier one could release a hold the report did not have. Coinbase Advanced Trade's responses carry no time and its acks stay unstamped; Gemini acks come from its order stream, which is stamped. Where a balance snapshot is stamped with the connector's clock as the reply arrives (Binance, Coinbase), an order the venue took while that reply was on its way reads as in the snapshot.
A report can also arrive after an event the venue stamped later than the report: Binance's WS API answers an order 1-5 ms before the user stream pushes the account, so the push from before a new order (or before a cancel) can come after its ack. What the estimate moved for such events is moved again on top of every report older than them, until a report stamped at or after them arrives; the report does not drop a new order's hold, or hold a cancelled order again.
Check 21 refuses an order whose hold, less what the order it replaces holds, exceeds the row's free estimate; for a derivative, what it adds to its instrument's larger side, and only while the available margin is zero or more. A derivative order that is reduce-only or reduces the position passes, and so does one without initial_margin while the available margin is positive. A row the venue has not reported refuses nothing, so a venue without balance reports is not checked. [risk] check_balance = false keeps the rows and turns the check off.
A requote on a venue without replace ([engine] supports_replace = false) cancels the resting order and sends its new one only once the cancel's ack has released the old order's hold, so the check sees the balance without it and the two never rest together. If the old order filled instead, the new one is checked against the balance after the fill: one the balance no longer covers is withheld rather than refused, and the strategy's next quotes decide. fastmm-gateway checks the account's balance over every attached strategy's orders as well (Account guards).
Currencies¶
Price, Qty and Notional carry no currency. PnL, fees, max_order_notional, min_notional and max_loss are all denominated in the instrument's settlement currency: the quote currency for a linear contract, the base coin for an inverse (coin-margined) one.
Without [accounting], instruments of one session must settle in the same currency (an inverse perpetual and options quoted in its coin, Deribit's BTC-PERPETUAL and BTC options, both settle in BTC); otherwise the PnL totals add unrelated numbers and max_loss compares the sum with one limit. fastmm-live checks this after the venues' reference data has loaded (InstrumentTable::settlement_mix()): with max_loss set it refuses to start, otherwise it warns.
With [accounting] (Configuration) they can mix. Positions, PnL and fees stay in each instrument's currency (PositionTracker also keeps them summed per currency); the totals, max_loss and the exposure caps are in the reporting currency, each other currency converted at the mid of its FX source's book (core/fx.hpp). The conversion runs where the totals change, on a fill, a mark or a new rate, not per order. The rules fail closed:
- A rate is unknown until its source's book is valid. While it is invalid, or has not updated for
[accounting] stale_fx_ms(default 60 s), an order that adds to exposure in that currency is refused (FxRateUnknown); one that reduces a position passes, and so does a flatten. - PnL already booked stays converted at the last valid rate: a stale source does not hide a loss that was already measured, and
max_losskeeps being evaluated on it. A currency whose rate was never known counts as zero; nothing can be traded in it before it is known, so only a restored or reconciled position can sit there. - With neither
max_lossnor an exposure cap set, the rate refuses nothing: the conversion only feeds the reports.
Net position per underlying¶
[risk.underlying.<BASE>] max_net limits the net position in one base asset over every instrument of the session that has that base, on every venue (Configuration). Long 0.3 BTC on Binance USD-M and short 0.3 BTC on Bybit is 0 BTC. The unit is the base asset, so the limit needs no price and no [accounting].
- Base units: A linear contract (or spot) counts
qty * contract_multiplier: 30 OKXBTC-USDT-SWAPcontracts of 0.01 BTC are 0.3 BTC. An inverse contract countsqty * multiplier / mark, its value in the base coin at the current mark: 150 contracts of 100 USD at 50000 are 0.3 BTC, and the same position is 0.375 BTC at 40000. - The check is the underlying's version of
max_position: the position now, plus the open orders on the order's side over every instrument of the underlying, plus the order (a replace excludes the order it replaces). The order is refused when the absolute value of that would passmax_netand be further from zero than the position is now; an order that brings the underlying towards zero always passes, also while it is over the limit. - Marks: The mark is the instrument's last valid mid, not older than
stale_md_mswhen that is set. An inverse contract with a position or open orders and no such mark makes the whole underlying unmeasurable: every order on it is refused (UnderlyingMarkUnknown) until the book is back. A flatten is not checked. - Options do not count: Their exposure in the underlying is a delta, which needs a pricing model the engine does not have; an option neither adds to the net nor is checked. A
[risk.underlying]section whose base asset only options have is a configuration error. - What is not counted: balances and margin in the base coin (the collateral of an inverse contract is BTC too), and positions held outside the session. Behind
fastmm-gateway,[gateway.underlying]holds the account's net over every strategy (Run behind a gateway).
Without the section the check costs a branch per order. With it, each order sums the underlying's instruments (a few array reads per instrument); fastmm-ctl limits underlying.BTC.max_net=... changes a limit at run time, for an underlying the configuration names (max_net = 0 names one without a limit).
Inverse contracts¶
An inverse contract's size is quoted in the quote currency (Deribit BTC-PERPETUAL: 10 USD per contract) and settles in the base coin, so its PnL is not linear in the price:
realized = qty * multiplier * (1 / avg_entry - 1 / exit) coins
unrealized = qty * multiplier * (1 / avg_entry - 1 / mark) coins
notional = qty * multiplier / price coins
The average entry price of an inverse position is the size-weighted harmonic mean: two lots of 10 contracts at 50000 and 40000 average to 44444.44444444, not 45000. Instrument::pnl_per_tick() is zero for an inverse contract; its tick value depends on the price.
Instrument::kInverse is set from Deribit reference data, not from the configuration. An inverse contract on another connector is booked as a linear one.
Valuation at the venue mark¶
A derivative's venue publishes a mark price (PerpStateMsg, Venues); liquidations and the venue's own unrealized PnL use it. With [accounting] mark = "venue" (the default) a position whose instrument has a mark younger than stale_mark_ms (default 15 s) is valued at it: its unrealized PnL, the net PnL max_loss measures and the exposure the notional caps sum (include/fastmm/core/perp_book.hpp). A new mark revalues the position and checks max_loss at once; book updates do not move it. When the mark goes stale the next book update values the position at the mid again, logged and counted, until a fresh mark arrives. An instrument without marks (spot) is valued at its mid. mark = "mid" values every position at its mid, as before the marks existed.
The decision runs on the engine clock, which the journal records, so a replay values and trips at the same events. fastmm-gateway's account follows the same setting with its own clock, so [gateway] max_loss and the exposure caps see the same values as the strategies'. The price collar, the fat-finger check, [risk.underlying] and the FX rates keep the mid.
The kill switch¶
The kill switch is a 32-bit flag word that any thread can set: bit 0 is global, bit 1 + v is venue v. While a bit is set, checks 1 and 2 refuse every new order and replace. Tripping the global bit also turns quoting off (on_quoting(false)), pulls every quote and cancels every working order; tripping a venue's bit does that for that venue's instruments only. The engine records why each bit was first set (a KillReason, shown by fastmm-top).
The global switch trips when:
- the session shuts down: Ctrl-C, SIGTERM,
--durationor an order ring overflow (the control thread requests it, then cancels all orders on every venue over a separate REST connection); [risk] max_lossis reached: net PnL (realised plus unrealised, valued at the venue's mark or the mid (Valuation), minus fees) plus the PnL carried over from earlier sessions is re-evaluated on every book update, venue mark, fill, funding payment and position snapshot;- the outbound ring to a venue or the journal ring is full, because the engine can no longer guarantee that what it sends is what it records;
- every venue with instruments has been killed;
- a strategy hook reports an error (
StrategyError, Python hot hooks); - the session's 32-bit client order id sequence is used up.
A venue's switch trips when its connector reports an error that makes the venue unusable (a bad key, signature or permission, failed authentication, a Binance IP ban), when the venue-side dead man's switch could not be refreshed in time, or when a multicast feed cannot rebuild its books. Behind fastmm-gateway, the gateway's max_loss or an operator's kill trips every venue of every attached strategy (GatewayMaxLoss, GatewayOperator). The command travels through the venue's order ring, so it is journaled and a replay trips it at the same point.
A max_loss trip is latched on disk ([engine] kill_file) together with the cumulative realized PnL and fees of every session since the file was last cleared. The next start reads that carry into the budget and refuses to trade while the trip is latched. Unrealized PnL is not carried; the position is remeasured from the venue's view after the restart.
Nothing resets a kill switch automatically. After a kill the engine tripped itself (every cause but the shutdown), [engine] on_kill decides whether fastmm-live shuts down. The default, exit, cancels all orders and exits with code 6. Kill switch and shutdown has the behaviour, the log lines and the shutdown sequence.
What the layer does not do¶
- It does not replace the strategy's own limits.
first_mmstops quoting a side atmax_position;[risk] max_positionis a second, independent limit that holds even when the strategy has a bug. Set the risk limit above the strategy's. - Position limits are per instrument, and per base asset with
[risk.underlying]. The portfolio-wide limits aremax_gross_notional,max_net_notionalandmax_loss, over one session; with instruments in more than one settlement currency they need[accounting](Currencies). Across strategies onlyfastmm-gateway's[gateway]limits see the account. - It knows no venue rules beyond reference data, the account's balances and margin (check 21, Balance check) and its own order rate limit (check 22). The connectors back off on the venue's rate-limit responses (Venue connectors), and the engine pauses a side after venue rejects (
[engine] reject_backoff_ms). Cancels never take a token. A quote (set_quotes) short of one is not refused: it waits, and the engine places the waiting instruments' latest quotes in the order they ran out as the bucket refills;[engine] quote_token_reservekeeps tokens for orders the strategy sends itself and for quotes that reduce the position. - It does not protect against a venue that stops answering. Order-channel loss triggers a REST cancel-all in the connector; beyond that, see Kill switch and shutdown.