Positions, PnL & Real-Time Risk
Know your exposure at every tick.
Mastery · Markets, Strategies & Getting Hired
Why this week
Week 15's pre-trade checks answer the question may I send this order? This week answers the other one: what do I own right now, and what is it worth? Post-trade, real-time risk keeps the position and PnL of every account in every instrument up to date on every fill and every price tick, and stops trading when a limit is breached. It has to keep up with the trading itself. A risk view that lags by a second is a second in which a runaway strategy can keep trading.
The failure this protects against has a well-known example. On 1 August 2012, a deployment error at Knight Capital reactivated old code in its order router. In about 45 minutes the firm accumulated billions of dollars of unintended positions and lost roughly $460 million. The SEC's 2013 order found that Knight's risk controls were inadequate under the Market Access Rule, including the lack of controls that would have stopped orders once exposure limits were exceeded. A system that knew its true exposure and enforced a hard limit would have stopped the damage within seconds.
Underneath the engineering is a precise accounting model. Total PnL is exact and needs no convention. The split between realized and unrealized depends on the accounting convention you choose. Positions flip from long to short in a single fill, and multipliers, overflow and both sides of every trade all need handling. You'll build the model in two drills, then put it on the exchange's hot path within a tight nanosecond budget.
Day 1 — The accounting model
Cash, position and one exact identity
Everything else is a convention
For one instrument, keep two integers. Position q is signed lots: + long, − short. Cash is −Σ signed_qty × price over every fill: buying costs cash, selling brings it in. Then at any mark price M:
total PnL = cash + q · M (times the contract multiplier)That's exact in integer arithmetic and convention-free. It's the number to check limits against, and the number that has to reconcile with your clearing statements.
Realized versus unrealized splits that total, and the split depends on how you match closing trades against opening ones:
- Average cost: one running average entry price. Closing trades realize
(price − avg) × closed. This is today's drill. - FIFO: close the oldest open lots first. Each lot keeps its own entry price.
- LIFO and others: other matching rules, sometimes required for tax or regulatory reporting.
Buy 1 at 100, buy 1 at 110, sell 1 at 120. Average cost realizes 120 − 105 = 15. FIFO realizes 120 − 100 = 20. The total at any mark is identical under both. Choose a convention deliberately, document it, and never set a limit on realized PnL alone. A strategy holding a large unrealized loss can show a healthy realized PnL right up to the moment it closes the position.
Read the description of what happened on 1 August 2012 and the findings about risk controls. Note how many separate controls (deployment checks, alerts, exposure limits) could each have stopped it, and that none of them was automatic.
Requires broker-dealers with market access to have risk controls, including pre-set credit and capital thresholds and checks that prevent erroneous orders, under their direct and exclusive control. It's the regulatory backdrop to weeks 15 and 45: risk checks are a legal requirement, not an optional feature.
Two systems report the same fills but different realized PnL. Is one of them wrong?
Day 2 — Average price through flips
Open, add, reduce, close, flip
Five cases, one function, one invariant
A fill does one of five things to a position. It opens one from flat, adds to it on the same side, reduces it, closes it to exactly zero, or flips it, closing the whole position and opening the remainder on the other side in one fill. The average-cost rules:
- Open or add: the new average is the quantity-weighted average of the old position and the fill.
- Reduce or close: realize
(px − avg) × closed, signed by the position (a short profits when the price is below its average). The average doesn't change, and it resets when you're flat. - Flip: realize on the whole old position, then the remainder opens at the fill price.
Flips are where hand-written position keepers go wrong. The fill quantity has to be split into a closing part and an opening part, and the remainder must get a fresh average, not a blend with the old one.
Average price through position flips
/// Average-cost position keeping for one instrument. Quantities are signed
/// lots (+ long, − short); prices are integer ticks.
pub struct Position {
qty: i64,
avg_px: f64,
realized: f64,
cash: i64,
}
impl Position {
pub fn new() -> Self {
todo!()
}
/// Apply a fill: signed_qty > 0 buys, < 0 sells, at `px`.
/// • flat or adding to the same side: avg_px becomes the
/// quantity-weighted average of the old position and the fill;
/// • reducing: realize (px − avg_px) per lot closed, with the sign of
/// the position (a short profits when px < avg_px); avg_px is
/// unchanged, and resets to 0.0 when you end flat;
/// • flipping (selling more than you are long, or vice versa): close
/// the whole position as above, then open the remainder at px.
/// Also keep `cash` exactly: buying costs px·qty, selling earns it.
pub fn fill(&mut self, signed_qty: i64, px: i64) {
todo!()
}
pub fn qty(&self) -> i64 {
todo!()
}
/// Average entry price of the open position; 0.0 when flat.
pub fn avg_px(&self) -> f64 {
todo!()
}
/// Realized PnL so far (average-cost convention).
pub fn realized(&self) -> f64 {
todo!()
}
/// Unrealized PnL at `mark`: qty · (mark − avg_px).
pub fn unrealized(&self, mark: i64) -> f64 {
todo!()
}
/// Total PnL at `mark`, exact and convention-free: cash + qty · mark.
/// Invariant: realized + unrealized(mark) ≈ total(mark).
pub fn total(&self, mark: i64) -> i64 {
todo!()
}
}The drill keeps the average price in f64 because an average of integer prices generally isn't an integer. Production systems handle this in different ways: fixed-point decimals with a documented rounding rule, the open cost kept as an exact integer next to the quantity, or rationals. Whichever you choose, keep the exact cash and qty as the source of truth. If the derived numbers ever disagree with them, the derived numbers are wrong.
You're long 15 at an average of 105 and sell 25 at 100. What are your realized PnL change, new position and new average price, and why isn't the new average a blend of 105 and 100?
Day 3 — Marking to market
What is it worth right now?
Marks, multipliers and O(1) updates
Unrealized PnL depends on the mark, and choosing it is a risk decision:
- Last trade: simple, but goes stale in a quiet market and can be moved by a single small print.
- Mid: the usual default for liquid instruments. Undefined for a one-sided book (week 41).
- Liquidation value: longs marked at the bid, shorts at the ask. Conservative: it's roughly what you'd get if you had to exit now, before market impact.
- Settlement price: the exchange's official daily price. For futures it determines daily variation margin, so it's the mark your clearing firm actually uses.
Whichever you choose, guard against bad marks. If the book is crossed, the data is stale or a price jumps by many standard deviations, hold the last good mark and raise an alert. Otherwise one bad tick can trigger a loss limit and halt trading for a move that never happened.
Multipliers convert price points into money. One E-mini S&P 500 future is $50 × the index, so a 10-point move on 2 contracts is $1,000. Forgetting the multiplier is the classic bug that understates risk by 50×.
Updating has to be O(1) per tick. A tick for one instrument changes that instrument's contribution and nothing else, so update the running totals by the delta: Δpnl = mult × q × (new − old), and for gross exposure, mult × (|q·new| − |q·old|). Re-summing thousands of instruments on every tick makes the hot path proportional to the size of the portfolio.
Mark-to-market on every tick
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Breach {
MaxLoss,
MaxGross,
}
#[derive(Clone, Copy, Debug)]
pub struct Limits {
/// Breach when total PnL ≤ −max_loss.
pub max_loss: i64,
/// Breach when gross exposure > max_gross.
pub max_gross: i64,
}
/// Positions and PnL across instruments, marked to market on every tick.
/// Every update must be O(1): a portfolio can hold thousands of
/// instruments and ticks arrive for one at a time.
pub struct Portfolio {
// Your fields. Per instrument you need at least: multiplier, mark,
// qty, cash. Then keep running totals for pnl, gross and net.
}
impl Portfolio {
/// One slot per instrument: contract multiplier and initial mark.
pub fn new(multipliers: Vec<i64>, marks: Vec<i64>, limits: Limits) -> Self {
todo!()
}
/// We traded `signed_qty` (+ buy, − sell) of `inst` at `px`. The mark
/// does not move. Returns the breach (if any) after the update.
pub fn on_fill(&mut self, inst: usize, signed_qty: i64, px: i64) -> Option<Breach> {
todo!()
}
/// New mark for `inst`. O(1): update the running totals by the delta
/// for this instrument only. Returns the breach (if any) after it.
pub fn on_tick(&mut self, inst: usize, mark: i64) -> Option<Breach> {
todo!()
}
/// Σ mult · (cash + qty · mark)
pub fn pnl(&self) -> i64 {
todo!()
}
/// Σ mult · |qty · mark|
pub fn gross(&self) -> i64 {
todo!()
}
/// Σ mult · qty · mark
pub fn net(&self) -> i64 {
todo!()
}
/// Latched kill switch: true once any update has returned a breach,
/// and it stays true even if the breach clears. (MaxLoss is checked
/// before MaxGross.)
pub fn killed(&self) -> bool {
todo!()
}
}
The mark is 100 and you buy 10 at 101. What happens to total PnL at the moment of the fill, and why?
Day 4 — Exposure, aggregation and reconciliation
From one position to a firm-wide view
Net, gross, hierarchies, and the truth from outside
Limits apply at several levels: per instrument, per account, per strategy or desk, and firm-wide. They use different measures:
- Net exposure Σ q·M·mult: the directional bet. A long future hedged by a short in a correlated future nets out.
- Gross exposure Σ |q·M·mult|: how much you'd have to unwind. Hedged books can still have huge gross, which matters when the hedge stops working or liquidity disappears.
- Per underlying. Positions in different instruments on the same underlying (future, ETF, basket) belong together. Options would need delta and the other Greeks, which is beyond this week, but the same aggregation structure applies.
Arrange limits hierarchically: a fill updates its account, which updates the desk, which updates the firm, each with its own limits. With incremental deltas the cost stays O(depth of the hierarchy) per fill.
Then reconcile. Your internal position is what you think you did. Exchanges offer drop copy sessions that send an independent copy of every execution, and the clearing firm's statements are the final authority. Compare continuously during the day with drop copy, and at the end of the day with clearing. Any difference is an incident, because it means something (a missed fill, a duplicate, a wrong multiplier) is making your risk view wrong.
Design the limit hierarchy for your exchange's members: which measures (net, gross, loss, order rate) at which level (instrument, account, firm), which breaches block new orders vs cancel resting ones vs only alert, and who can reset each.
Day 5 — Post-trade limits and the kill switch
When a limit breaks
Latch, cancel, block — and what not to automate
Detecting a breach is the easy part. What happens next has to be designed in advance, because nobody will have time to think when it happens:
- Latch. The kill state stays set until a human resets it, even if the condition clears on the next tick. A price that wobbles around the limit must not switch trading on and off repeatedly.
- Block new orders for the account immediately. That's week 15's kill switch, now triggered by post-trade state.
- Cancel resting orders. Many exchanges offer mass-cancel or cancel-on-disconnect, and your exchange should too. Make cancel-all idempotent: sending it twice must be safe, because under stress it will be.
- Don't flatten automatically without a policy. Aggressively closing a large position in the conditions that just caused the loss can make it much worse. Whether to flatten, hedge or hold is a decision for a person, with information, unless the policy says otherwise and has been tested.
- Alert with context: what breached, by how much, the positions, the last N fills, and the marks used.
Detection time is part of the design. Checking limits on every fill and every tick, as today's drill does, means a breach is caught within one event. Checking on a timer every second gives a runaway strategy up to a second of trading, and Knight's 45 minutes shows how much damage that kind of window allows.
Why must the kill switch latch instead of re-enabling trading automatically once PnL recovers above the limit?
Day 6 — On the hot path
Tens of nanoseconds per fill
Dense state, single writer, snapshot readers
Position keeping sits on the exchange's critical path. Every trade updates two accounts, and every market-data tick moves marks. To stay within a budget of tens of nanoseconds:
- Dense state.
AccountIdandInstrumentIdare small integers, so index a flat array of(account, instrument)slots directly, or one array per account. A defaultHashMapuses SipHash, which costs tens of ns per lookup. If you do need a map, use a faster hasher and pre-size it. - Both sides of each trade. Each
Tradeevent names a taker and a maker order, not accounts. Keep an order → account index, populated when orders are accepted (week 27's slab works well here). One fill then touches two position slots plus the hierarchy totals. - Integer money, wide enough. Price ticks × quantity × multiplier can overflow
i64for large notionals. Do the per-fill arithmetic ini64where the ranges are proven small, and accumulate notional and firm-wide totals ini128. - Single writer. One risk thread applies fills and ticks in the engine's sequence order, so replay gives exactly the same positions (week 9). Dashboards and monitors read snapshots through week 34's seqlock. They never take a lock on the writer's data.
Sketch the memory layout of your position state: slot size in bytes, how slots are indexed, how many cache lines one fill touches (taker, maker, hierarchy totals), and where the order → account mapping lives. Then estimate the fill-update cost from the cache-line count.
Capstone
Build real-time position keeping in crates/risk and connect it to the engine's event stream and week 15's kill switch.
State. Dense per-(account, instrument) slots holding position, cash and the average-cost fields from the drill, plus running totals per account (PnL, gross, net) updated by deltas. Keep a per-instrument mark, and an order → account index populated from accepted orders.
Updates.
- On every
Tradeevent, apply the fill to both the taker's and the maker's account, with opposite signs, at the trade price. A self-trade (same account on both sides) nets to zero position but still counts toward volume and fees, so make sure it neither double-counts nor disappears. - On every mark update, apply the O(1) delta for that instrument to every account that holds it. For many accounts, keep a per-instrument list of holders, or mark lazily per account with a version counter. Choose one and justify it.
Limits. Per-account loss (on total PnL) and gross exposure, checked after every update. A breach latches the account's kill switch from week 15, which blocks new orders and triggers cancel-all.
Determinism. Apply events strictly in engine sequence order, and prove it with a replay test: the journal replayed into a fresh risk engine gives identical positions and PnL.
What the repo has. risk::pnl (src/pnl.rs) builds Position and Positions. PostTradeLimits, Breach and AccountPnl are provided. A few of its choices differ from the drills, deliberately:
- No stored average. A
Positionholds the quantity, the realized PnL and the exact cost basisΣ price × signed qtyini128. That's the "open cost kept as an exact integer" option from Day 2. A partial reduction releasescost × closed / |qty|truncated toward zero, and the sub-tick remainder stays in the basis until those lots close. So realized PnL is exact whenever a position returns to flat, andavg_price()(cost / qty, truncated) is for display only. Cash isn't stored either, because it always equalsrealized − cost. - Money is ticks × lots, with no contract multiplier.
- Lookups. Slots are cache-line sized and found through
IdMap, the crate's fast integer-key hash map, keyed by (account, instrument). Each account's realized, cost, net and gross totals are kept by deltas, soapply_fillis O(1). - Marks. One mark per instrument, set by
set_mark(instrument, mark, on_breach). Until it's first called, an instrument's mark is the price of its first fill.set_markre-values the holders through a per-instrument list, O(holders). - Both sides of a trade. The order → account index already lives in week 15's
RiskEngine.risk.on_event(&event)returns aFillfor the taker and one for the maker, and each goes topositions.apply_fill(&fill). - Limits.
PostTradeLimits { max_loss, max_gross_exposure }per account, with a default for accounts that have none. Loss is checked on realized + unrealized.apply_fillreturnsErr(Breach), andset_markcallson_breach(account, breach)for each holder beyond a limit. Breaches are reported on every update while the account stays beyond a limit.Positionsdoesn't latch anything itself: the caller calls week 15'srisk.kill_account(account), which stays engaged untilrevive_account, and cancels the orders inopen_orders(account).
The w45_ tests cover average cost, flips, shorts and negative prices, the rounding remainder, per-account aggregation at marks, a max-loss breach that trips RiskEngine's kill switch, a gross-exposure breach, and 3,000 randomized fills and marks compared with a from-scratch replay of each position's fill list. The journal replay test is yours.
Reading the benchmark. The fill-update target is a p99 of tens of nanoseconds for applying one fill to positions and PnL. w45.fill_update times Positions::apply_fill on 4,096 open positions (1,024 accounts × 4 instruments), with the limits checked on every fill and a stream of fills that open, add to, reduce and flip positions. That's a few cache-line touches and some integer arithmetic. It leaves no room for a SipHash lookup, an allocation, a lock or a float-to-string conversion. If you're over budget, count cache lines before you count instructions.
Pitfalls:
- Updating only the taker.
- Limits on realized PnL.
- A missing multiplier.
i64overflow in notional sums.- Marks taken from a crossed or stale book.
- A kill switch that resets itself.
- Readers taking a lock on the writer's state.
crates/riskReal-time positions, PnL and post-trade limits
Incremental position and PnL keeping in crates/risk per (account, instrument), applied to both sides of every engine trade with flips handled and exact integer totals, marked to market on every tick, with post-trade limit breaches latching the week 15 kill switch.
cargo xtask test --week 45 # tests named w45_*
cargo xtask bench --week 45 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 45 tests pass w45_*
- Apply fill to positions + PnL w45.fill_updatetarget p99 ≤ 95 ns
Feeds into
No later week lists this one as a formal prerequisite, but its ideas come up again. Week 48's chaos scenarios should include a runaway strategy and check that this week's limits stop it within one event. Week 49's system design interviews often include "design a real-time risk system", and the layout, hierarchy and kill-switch decisions you made here are the substance of a good answer.