Lesson 3 — AccountSnapshot + CloseOrderSpec — the engine's input and output types
Question
AccountSnapshot is the input (everything the engine needs to evaluate one account). CloseOrderSpec is the output (the order spec the matching engine consumes). Bridge between Account state and CLOB submission.
Principle (minimum model)
AccountSnapshotfields.position_size + entry_price + collateral + mark_price + market_id + liquidation_params. Read-only.- Snapshot is point-in-time. Built by the scanner from current state; engine compute is then pure on the snapshot.
CloseOrderSpecfields.side + qty + market_id + reason. Matches the matching engine'ssubmit_marketsignature.- Reason field.
enum CloseReason { Liquidation, AdminClose }. Lets the matching engine log differently. sideis opposite of position. Long → SELL close. Short → BUY close. Encoded in CloseOrderSpec construction.qtyis full position size. No partial liquidation in the first version; production sometimes allows partial.- Implicit type-safety. Building a CloseOrderSpec from an Underwater position implies all the conservation logic; type system catches misuse.
Worked example + steps
Lesson 3 — AccountSnapshot + CloseOrderSpec — the engine's input and output types
Goal
Concepts you'll grasp in this lesson:
- Why liquidation defines its own
AccountSnapshotinstead of reusingfunding::Position—Positioncarries(account, size); liquidation needs(account, size, avg_entry, collateral). Two crates, two snapshot types, no cross-coupling. The bridge layer assembles each from its own ledger. - The "snapshot" discipline shared with funding — the engine consumes a snapshot the caller built; it never owns mutable account state. Same I/O-free purity that lets the proptest catch determinism bugs.
- Why
CloseOrderSpeccarries no price field — liquidation always closes at market. The engine doesn't pick prices; the bridge encodes this asclob::Action::SubmitMarketand the book settles at whatever the next available price is. - Why
SideandQtycome fromopenhl_clob, not a new liquidation-local type — they're the same concepts the matching engine speaks. Two parallelSideenums in two crates would be a translation surface waiting to drift.
Verification:
cargo build -p openhl-liquidation
…compiles. After this lesson, the types module is complete.
Specific changes:
src/types.rs— appendsAccountSnapshotandCloseOrderSpecstructs below the existingMarginHealthenum. No changes to anything from Lesson 1 or Lesson 2.src/lib.rs— addsAccountSnapshotandCloseOrderSpecto thepub use types::{...}re-export.
Lesson 3 still has no tests — both new structs are passive data containers. Lesson 4 begins the compute module and earns the first behavior test (notional_value).
Recap
After Lesson 2:
types.rshasMARGIN_SCALE+LiquidationParams(Lesson 1) +MarginRatio+MarginHealth(Lesson 2).lib.rsre-exports four names:LiquidationParams,MarginHealth,MarginRatio,MARGIN_SCALE.cargo build -p openhl-liquidationpasses with zero warnings.
Lesson 3 adds the two I/O types: the input every margin function consumes (AccountSnapshot) and the output the engine hands the bridge (CloseOrderSpec). After Lesson 3, the types module is finished — Module 1 of Course 10 is closed.
Plan
Two edits, both append-only:
- Append
AccountSnapshottocrates/liquidation/src/types.rs— 4 fields,Copy-friendly, doc comment that names the caller's responsibility for maintainingavg_entryacross fills. - Append
CloseOrderSpecbelow that — 3 fields, no price, doc comment that names the bridge as the consumer. - Update
crates/liquidation/src/lib.rs— extend thepub use types::{...}line.
(Answer: avg_entry (to compute the PnL leg) and collateral (to compute equity). Funding's formula has no entry factor — it scales by the current mark times the rate, regardless of where the position was opened. Funding also doesn't read collateral; the settlement deltas it emits get applied to balances at the bridge layer, which keeps its own balance ledger. Liquidation's job is to measure whether collateral + unrealized PnL has fallen below the threshold, so it needs both. Different jobs, different snapshots.)
Drawing what the types module — completed in Lesson 3 — receives as input and produces as output makes the joint between Module 1 (types) and Module 2 (pure compute) immediately legible:
[ Upstream: bridge / clearing layer (the ledger owner) ]
│
│ builds a snapshot per account, per tick,
│ pulled from its own ledger
▼
┌────────────────────────────────────────────────────────────────────┐
│ Input: AccountSnapshot { account, position_size, avg_entry, │
│ collateral } │
│ ※ Immutable, read-only, Copy. Finalized in Lesson 3. │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ ★ The liquidation engine (everything Module 2-4 builds) │
│ │
│ Lesson 4: notional_value / unrealized_pnl (pure compute) │
│ Lesson 5: account_equity / margin_ratio (pure compute) │
│ Lesson 6: margin_health (classification: 4-state) │
│ Lesson 7: close_order_spec (Liquidatable / Underwater) │
│ ↑↑ The constants and types from Lessons 1–2 (MARGIN_SCALE, │
│ LiquidationParams, MarginRatio, MarginHealth) flow through │
│ every layer. │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ Output: CloseOrderSpec { account, side, qty } │
│ ※ No price (market order). Only emitted for Liquidatable / │
│ Underwater accounts. Finalized in Lesson 3. │
│ Module 3-4 emits InsuranceFundDelta alongside. │
└────────────────────────────────────────────────────────────────────┘
│
▼
[ Downstream: bridge → matching engine (CLOB) ]
- convert close orders into `SubmitMarket` actions
- credit/debit the insurance fund on Underwater paths
Two things this picture pins down: (a) The two types finalized in Lesson 3 — AccountSnapshot (input) and CloseOrderSpec (output) — are the engine's only contact surface with the outside world. All the engine body lives in Lesson 4 onward, but every function signature lands on "consume an AccountSnapshot" or "emit a CloseOrderSpec." (b) Both the input (snapshot) and the output (spec) are immutable — the engine never mutates the ledger; full ownership of the ledger stays on the bridge side. This is the concrete shape of what Lesson 0 previewed as "a read-only snapshot type that keeps the risk-calculation core decoupled from upstream state."
Walk-through
Step 1: Append AccountSnapshot to src/types.rs
Open crates/liquidation/src/types.rs. After the closing } of the MarginHealth enum, append:
/// Snapshot of one account's perpetual-market state, assembled by the
/// bridge layer before invoking the liquidation engine. Same "snapshot"
/// model as `openhl_funding::Position`: the engine treats this as a
/// per-tick read-only view, never mutates it.
///
/// `avg_entry` is the volume-weighted average price at which the account
/// opened its current net position. The owning layer (vault / clearing)
/// is responsible for maintaining this across fills.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AccountSnapshot {
pub account: AccountId,
pub position_size: PositionSize,
pub avg_entry: MarkPrice,
pub collateral: Notional,
}
Things to notice about this 10-line block:
-
Four fields, all
Copy.AccountId(u64),PositionSize(i64),MarkPrice(u64),Notional(i64). Total stack size: 32 bytes. The engine passes snapshots by reference (&AccountSnapshot) in most calls but theCopyderive means a caller that accidentally drops a&reference doesn't get a borrow-checker fight. -
avg_entry: MarkPrice, not a newEntryPricetype. The price at which a position was opened lives in the same unit-of-account as the mark price the position is currently measured against. Defining a separateEntryPricenewtype would force conversions at every PnL computation site for no semantic gain. When two fields measure the same physical thing, share the type. -
collateral: Notional— signed. Collateral is deposited funds, conventionally non-negative, but the type isNotional(signed) becauseaccount_equity = collateral + unrealized_pnlneeds to flow as a signed sum. Makingcollateralunsigned would force anas i64cast in every equity computation. Convert at the boundary, keep the math in one signed type — that way, silent runtime bugs caused by missed casts or mixing signed and unsigned (underflows, anascast that flips the top bit, a subtraction that should have produced a negative number turning into a large positive one) get eliminated at the compile-level as type mismatches. Lesson 4's sign trick — computing(mark − entry) × sizebranchlessly for all four quadrants — only works because every step on that path is uniformly signed. -
pubfields, no constructor function. Same convention asLiquidationParamsfrom Lesson 1: transparent struct, no encapsulation invariant. The bridge layer buildsAccountSnapshot { account: …, position_size: …, … }directly. There's noAccountSnapshot::new()because there's nothing for a constructor to enforce. -
Doc comment names the caller's contract. "The owning layer (vault / clearing) is responsible for maintaining this across fills." That single sentence is the entire
avg_entryinvariant: liquidation doesn't track fills, doesn't recompute entry, doesn't reconcile partial closes. Those responsibilities live one layer up. The crate doc says what this crate guarantees; what it requires from the caller goes in the type's doc comment.
Step 2: Append CloseOrderSpec to src/types.rs
Continue in src/types.rs. After the closing } of AccountSnapshot, append:
/// Specification for a single liquidation close order, generated by the
/// engine and consumed by the bridge layer. The bridge encodes this as
/// `openhl_clob::Action::SubmitMarket` and routes it through the matching
/// engine.
///
/// Always a market order — liquidation accepts any available price.
/// Always the opposite side of the position: a long position closes via
/// `Side::Sell`, a short via `Side::Buy`. Quantity is the absolute value
/// of the position size.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CloseOrderSpec {
pub account: AccountId,
pub side: Side,
pub qty: Qty,
}
Three things to notice:
-
No
pricefield. Liquidation never picks a price; the engine produces a market order spec and the matching engine fills it at whatever depth exists in the book. Stage 10c will iterateAccountSnapshotslices and emit oneCloseOrderSpecperLiquidatableorUnderwateraccount; none of them will carry a limit. -
side: Sidereusesopenhl_clob::Side. The matching engine speaks inSide::{Buy, Sell}. If we defined a newliquidation::Sideenum and converted at the bridge, we'd be introducing an unnecessary translation layer (animpl Fromand its inverse) that becomes a source of future type drift — someone adds a third variant (Closing, say) to one crate but not the other, or quietly inverts theBuy ↔ Sellmapping in one spot. One enum, one source of truth. Vocabulary that crosses crate boundaries (Side,Qty) should be shared across the boundary so you don't end up paying a permanent type-conversion cost (an "adjustment tax") forever. -
qty: Qtyreusesopenhl_clob::Qty(u64). The doc comment says "absolute value of the position size" —PositionSizeisi64(signed) but the close quantity is always positive. The conversion (Qty(position_size.0.unsigned_abs())) happens incompute::close_order_specat Lesson 7; here we just commit to the output type being unsigned.
(Answer: No. The bridge consumes the spec and needs to do two things: submit the close order, and (for Underwater accounts) credit the insurance fund. The engine signals both — Stage 10c's scanner emits the CloseOrderSpec plus an InsuranceFundDelta for accounts that were Underwater. Adding a Reason field to the close spec would duplicate signal between the spec and the insurance-fund delta, and a future refactor could let them drift apart. Don't encode the same fact in two places — let the upstream output be the source of truth, and let downstream consumers carry only what they need.)
Step 3: Update src/lib.rs
Open crates/liquidation/src/lib.rs. Extend the pub use types::{...} line. Was:
pub use types::{LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE};
Becomes:
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
Two new names added — AccountSnapshot and CloseOrderSpec — alphabetically inserted (so AccountSnapshot lands at the start, CloseOrderSpec after it, and the rest follows in the same order). The line breaks across multiple lines once the list grows past ~5 items; rustfmt will reformat to a one-name-per-line block on the next save if you keep adding.
Step 4: Compile
cargo build -p openhl-liquidation
Expected output:
Compiling openhl-liquidation v0.1.0 (/Users/.../my-openhl/crates/liquidation)
Finished `dev` profile [unoptimized + debuginfo] in 0.4s
Zero warnings, zero errors. The types module of the liquidation crate is now complete.
Common errors:
error[E0432]: unresolved import 'openhl_clob::Qty'— the import line at the top oftypes.rsalready namesQty(added back in Lesson 1's types.rs scaffold), so this fires only if you stripped imports. If it does, the Lesson 1-era top of the file should still readuse openhl_clob::{AccountId, Qty, Side};anduse openhl_funding::{MarkPrice, Notional, PositionSize};— the same imports cover both Lesson 2 and Lesson 3.error: cannot find type 'Notional'— same root cause; check theuse openhl_funding::{…}line includesNotional.
Design reflection
Three load-bearing decisions in this lesson:
-
AccountSnapshotis liquidation-local, not a shared type inopenhl-funding. The two crates have different jobs — funding settles continuous rate-driven deltas, liquidation classifies discrete margin events — and forcing them to share a snapshot type would couple the bridge's data plumbing on both sides. Two crates with related-but-different needs deserve two snapshot types. -
CloseOrderSpeccarries no price. The engine's responsibility is to decide whether to close, not at what price. The bridge layer translates the spec into a market order and the matching engine takes whatever depth exists. Mechanisms that pick prices belong below the policy layer that decides actions. -
SideandQtycome fromopenhl_clob, not a parallel liquidation-local type. When two crates exchange messages, they should speak in the same vocabulary types. TwoSideenums means twoimpl Fromblocks at the boundary plus a coordination tax forever. Share the boundary types; specialize the internal types.
Answer key
cd ~/code/openhl-reference
git checkout 22eedf9
diff -u ~/code/my-openhl/crates/liquidation/src/types.rs ./crates/liquidation/src/types.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
After Lesson 3:
- types.rs matches the full Stage 10a types.rs byte-for-byte. Module 1 of Course 10 ships exactly this types module.
- lib.rs still misses
pub mod compute;+ the compute re-exports. Those land in Lessons 4–7.
Common questions
Q1: Could AccountSnapshot be generic over a position-type trait so funding and liquidation share an abstract snapshot?
Could, but premature. Both crates each fit on one page of fields they need; an abstract Snapshot<P: PositionLike> trait would add types-machinery the bridge doesn't need to manipulate. A concrete type per crate, with the bridge translating, is cheaper to read and cheaper to refactor.
Q2: Why does avg_entry use MarkPrice instead of a dedicated EntryPrice newtype?
Because the price at which a position was opened and the price the position is being measured against are in the same units — same scale, same source-of-truth (the matching engine's last fill price, conventionally). Defining EntryPrice(u64) parallel to MarkPrice(u64) would force conversions at every PnL site. When two values share units, share the type.
Q3: Is collateral allowed to be negative?
In the engine's eyes: no, the deposited collateral is always non-negative. But Notional is signed because (a) it's the type funding uses for settlement deltas, which can be negative, and (b) intermediate equity computations collateral + unrealized_pnl produce signed results. Making collateral itself unsigned would force casts at every equity site. Signed arithmetic upstream, range-check at the boundary.
Q4: Should CloseOrderSpec carry a bridge_metadata: Bytes field for upstream context?
No — Stage 10c will pass CloseOrderSpec directly to the bridge with no envelope. If you need to correlate a close back to its trigger (audit logs, telemetry), the bridge can do that with (snapshot.account, current_block_height) from outside the spec. Don't let downstream features balloon the upstream type.
Q5: Why are both structs Copy?
Cheap and convenient. AccountSnapshot is 32 bytes, CloseOrderSpec is 24 bytes — Copy is essentially free at these sizes. Without it, callers have to clone every time they want a second reference. Make small Plain-Old-Data types Copy; reach for Clone only when ownership semantics actually matter.
Next lesson (Lesson 4)
Lesson 4 starts the compute module. The first two functions — notional_value and unrealized_pnl — earn the first behavior tests for the liquidation crate. You'll see the signed-multiplication trick that makes the same code path produce the right sign for both long and short positions, and the i128-intermediate discipline that keeps multiplications safe from i64 overflow at network-pathological inputs.
Summary (3 lines)
AccountSnapshot= input (position + collateral + mark + liquidation params).CloseOrderSpec= output (side + qty + market + reason).- Snapshot is point-in-time; engine compute is pure on it. Close side = opposite of position; qty = full.
- No partial liquidation in v1. Next module: pure compute.