Lesson 7 — close_order_spec — Stage 10a's last function
Question
close_order_spec(snapshot, reason) -> CloseOrderSpec builds the CLOB submission spec from a snapshot. Stage 10a complete: the pure-compute crate is done.
Principle (minimum model)
- Signature.
fn close_order_spec(snapshot: &AccountSnapshot, reason: CloseReason) -> CloseOrderSpec. Pure. - Side = opposite of position.
if size > 0 { Side::Sell } else { Side::Buy }. - qty = full position size.
|size|(abs). - market = snapshot.market_id. Pass-through.
- reason comes from caller (scanner = Liquidation, admin = AdminClose).
- Type-safe construction.
CloseOrderSpec::new(...)constructor validates side / qty; refuses size = 0 (no liquidation of empty positions). - Tests. (1) Long position → Sell close. (2) Short position → Buy close. (3) Size = 0 → error.
- Stage 10a complete. Types + pure compute + close_order_spec. The crate exposes a clean API for the scanner (next module).
Worked example + steps
Lesson 7 — close_order_spec — Stage 10a's last function
Goal
Concepts you'll grasp in this lesson:
- The elementary close-the-position rule — long is closed by selling, short is closed by buying. Side is always the opposite of the position direction; the engine doesn't decide a side, it inverts one.
unsigned_absat the public boundary — the discipline from Lesson 4 (useunsigned_absoverabsfori64) shows up at the function that talks to the bridge. The outputQty(u64)is the type the CLOB matching engine expects; the engine pushes the sign-conversion to its own boundary.- Why
close_order_specdoesn't filter flat positions — a flat position generates a spec withqty == 0. The bridge filters before submitting. Keepingclose_order_spectotal and side-effect-free makes it composable with the Stage 10c multi-account scanner. - Single-responsibility scoping —
close_order_specdoesn't takeMarkPrice(market orders carry no price) orLiquidationParams(the decision-to-liquidate happens inmargin_health). One snapshot in, one spec out.
Verification:
cargo test -p openhl-liquidation
…passes 24 tests (21 from Lessons 4–6 + 3 new tests for the three close-side cases). Stage 10a is now byte-for-byte complete against 22eedf9.
Specific changes:
src/compute.rs— appendsclose_order_specaftermargin_health+ 3 unit tests inside the existing test module.src/lib.rs— extends the compute re-export to includeclose_order_spec.
Lesson 7 is the shortest lesson in Stage 10a. The function itself is 11 lines; the lesson exists to lock in the side-inversion rule and to mark the completion of the pure-compute module.
Recap
After Lesson 6:
compute.rshasnotional_value,unrealized_pnl,account_equity,margin_ratio,margin_health, plus thesaturate_i128_to_i64helper and 18 unit tests + 3 proptests.lib.rsre-exports 5 of 6 compute functions (everything exceptclose_order_spec).cargo testruns 21 tests, all green.
Lesson 7 closes Stage 10a. After this lesson, the answer-key diff against 22eedf9 is fully clean for both compute.rs and lib.rs.
Plan
Three edits:
- Append
close_order_spectocrates/liquidation/src/compute.rs— 11 lines plus the doc comment. - Add 3 unit tests in the existing test module — long-closes-with-Sell, short-closes-with-Buy, flat-position-has-zero-qty.
- Update
crates/liquidation/src/lib.rs— extend the compute re-export.
(Answer: Long: Side::Sell, Qty(10). Short: Side::Buy, Qty(10). Long is closed by selling: the trader holds 10 units long, so they need to sell 10 to flatten. Short is closed by buying: the trader has 10 units short, so they need to buy 10 to flatten. Quantity is always the magnitude of the position; the sign lives in the side, not in qty. Qty is u64 precisely because magnitude is sign-free.)
At its core, close_order_spec is just flipping the side of a position. Drawing the bridge between the CLOB (matching engine) and the liquidation engine in one picture makes it obvious why this function fits in 11 lines, and why it carries zero responsibility for picking a side or a price:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Held account position │ │ Inverted market order that │
│ (account state) │ │ close_order_spec emits │
├─────────────────────────────┤ ├─────────────────────────────┤
│ Long size = +10 │ ──[invert]──► │ Side::Sell qty = 10 │
│ (holds 10 units long) │ │ → submit "sell 10" to CLOB │
│ │ │ → consume bids until filled │
│ │ │ → position flattens │
├─────────────────────────────┤ ├─────────────────────────────┤
│ Short size = −10 │ ──[invert]──► │ Side::Buy qty = 10 │
│ (10 units short) │ │ → submit "buy 10" to CLOB │
│ │ │ → consume asks until filled │
│ │ │ → position flattens │
├─────────────────────────────┤ ├─────────────────────────────┤
│ Flat size = 0 │ ──[invert]──► │ Side::Buy qty = 0 │
│ (no position; shouldn't even │ │ → bridge filters; not submitted│
│ normally reach here) │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
※ `close_order_spec` decides only two things: "invert the direction" and
"extract the magnitude via `unsigned_abs`."
- The "should we liquidate?" decision is already settled by Lesson 6's `margin_health`.
- The "at what price?" decision happens in the matching engine (CLOB) against the book.
- The "don't submit flat specs" filter is the bridge's job before submission.
Each layer owns exactly one concern; they compose in series.
The point: the essence of this function is the side inversion, full stop. The Long ↔ Sell / Short ↔ Buy mapping is the smallest possible transformation that issues a netting trade through the CLOB; throwing MarkPrice or LiquidationParams into the signature would mix in price discovery or threshold decisions that belong elsewhere. The function expresses "close this position" in its smallest possible form — and that's all close_order_spec is for.
Walk-through
Step 1: Append close_order_spec to src/compute.rs
Open crates/liquidation/src/compute.rs. After margin_health, before the #[cfg(test)] block, append:
/// Generate the close-order spec for a liquidatable position.
///
/// Side is the opposite of the position direction (long → SELL, short →
/// BUY), quantity is the absolute position size. Always a market order
/// at the bridge layer — liquidation accepts any available price.
///
/// Flat positions produce a spec with `qty == 0`; callers should filter
/// these out before submitting, since the CLOB will reject a zero-qty
/// order. We don't filter here because liquidation engines typically scan
/// many accounts and a side-effect-free `close_order_spec` is easier to
/// compose.
#[must_use]
pub fn close_order_spec(snapshot: &AccountSnapshot) -> CloseOrderSpec {
let abs_size = snapshot.position_size.0.unsigned_abs();
let side = if snapshot.position_size.0 > 0 {
Side::Sell
} else {
Side::Buy
};
CloseOrderSpec {
account: snapshot.account,
side,
qty: Qty(abs_size),
}
}
Five things to notice about this 11-line function:
-
Side is always the opposite of position direction. The trader holds
sizeunits (positive = long, negative = short). To close, the engine submits an order on the other side: a long unwinds by selling, a short unwinds by buying. The matching engine doesn't care about the close's intent; it only sees an order on a side. The "opposite side" rule is the entire bridge between position direction and order side. -
unsigned_abs()returns the magnitude asu64. Same Lesson 4 discipline applied to the public boundary.Qtywraps au64, so the magnitude flows directly intoQty(abs_size)without an intermediateas u64cast. The function does sign conversion exactly once, at the boundary where signed position-size meets unsigned order-quantity. -
if snapshot.position_size.0 > 0— strict greater-than. A flat position (size == 0) falls into theelsebranch and getsSide::Buy. That's harmless because qty will also be 0 — the spec exists but it's meaningless. We don't special-case the flat path inside the function; the bridge filters specs withqty == 0before submitting. -
No
mark, noparams.close_order_speconly needs the snapshot. The "decision to close" lives inmargin_health; the price discovery happens at the matching engine. Each function owns exactly one concern. The bridge composes them: scan → classify → generate close spec → submit. -
Returns
CloseOrderSpecby value, notOption<CloseOrderSpec>. The function is total — it always returns a spec, even for flat positions (withqty == 0). The alternative —Option— would force the caller to handleNonefor every flat account in a scan, even though those accounts are already pre-filtered by the time we reach the close step. Total functions compose; optional functions force every caller to handle the empty case (with the boilerplate that comes with it). Where this matters concretely is Stage 10c'sLiquidationScanner: it can process every account snapshot uniformly through a plainmapor a flatforloop, with nofilter_mapand noOptionchaining. Becauseclose_order_specis total, the scanner writes the "is thisLiquidatableorUnderwater?" classification filter in one place, and doesn't need to re-filter at close-spec generation time. Edge-case filtering (don't submit a flat-qty spec) lives at the outermost shell of the system — the bridge — which is the discipline that runs across this whole crate.
Step 2: Add 3 unit tests
Inside the existing #[cfg(test)] mod tests { ... }, after the margin_health tests, add:
// ─── close_order_spec ──────────────────────────────────────────
#[test]
fn close_long_with_sell() {
let s = snapshot(10, 100, 0);
let order = close_order_spec(&s);
assert_eq!(order.side, Side::Sell);
assert_eq!(order.qty, Qty(10));
assert_eq!(order.account, AccountId(42));
}
#[test]
fn close_short_with_buy() {
let s = snapshot(-10, 100, 0);
let order = close_order_spec(&s);
assert_eq!(order.side, Side::Buy);
assert_eq!(order.qty, Qty(10));
}
#[test]
fn close_flat_has_zero_qty() {
// Flat position generates a zero-qty spec; callers must filter.
let s = snapshot(0, 100, 1_000);
let order = close_order_spec(&s);
assert_eq!(order.qty, Qty(0));
}
Things to notice:
-
close_long_with_sellasserts all three output fields. Side, qty, and account — every output field gets locked in. The bridge depends on all three; testing all three protects against a partial refactor that fixes one and breaks another. For output-type tests, assert every field the caller will read. -
close_short_with_buyskips the account assertion. The account field comes from the same input source asclose_long_with_sell; if it worked for long, it works for short. Cover the orthogonal axes once; don't repeat what previous tests already locked in. -
close_flat_has_zero_qtyexists despite the function not filtering the flat case. The test documents the contract: "we promise that flat positions produce zero-qty specs, callers must filter." If a future refactor accidentally added a filter insideclose_order_spec(returningDefault::default()or panicking on flat), this test would fail. Tests preserve documented contracts, including ones that say "we don't do this; the caller does."
Step 3: Update src/lib.rs
Open crates/liquidation/src/lib.rs. Extend the compute re-export. Was:
pub use compute::{
account_equity, margin_health, margin_ratio, notional_value, unrealized_pnl,
};
Becomes:
pub use compute::{
account_equity, close_order_spec, margin_health, margin_ratio, notional_value, unrealized_pnl,
};
One new name — close_order_spec — alphabetically inserted after account_equity. All six compute functions are now re-exported.
Step 4: Run the tests
cargo test -p openhl-liquidation
Expected output:
running 24 tests
test compute::tests::close_flat_has_zero_qty ... ok
test compute::tests::close_long_with_sell ... ok
test compute::tests::close_short_with_buy ... ok
test compute::tests::equity_can_go_negative ... ok
test compute::tests::equity_collateral_plus_pnl ... ok
test compute::tests::health_at_risk ... ok
test compute::tests::health_boundary_at_maintenance ... ok
test compute::tests::health_liquidatable ... ok
test compute::tests::health_safe ... ok
test compute::tests::health_underwater ... ok
test compute::tests::notional_flat_is_zero ... ok
test compute::tests::notional_long ... ok
test compute::tests::notional_short_uses_abs ... ok
test compute::tests::pnl_flat_is_zero ... ok
test compute::tests::pnl_long_loss ... ok
test compute::tests::pnl_long_profit ... ok
test compute::tests::pnl_short_loss ... ok
test compute::tests::pnl_short_profit ... ok
test compute::tests::ratio_can_be_negative ... ok
test compute::tests::ratio_exactly_ten_percent ... ok
test compute::tests::ratio_flat_returns_max ... ok
test compute::tests::long_ratio_monotonic_in_mark_when_levered ... ok
test compute::tests::margin_ratio_deterministic ... ok
test compute::tests::short_ratio_monotonic_in_mark ... ok
test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
24 tests passing. Stage 10a content is complete. The liquidation crate's pure-compute module — margin math + classification + close-order generation — is now in your workspace and the answer-key diff against 22eedf9 is fully clean.
Common errors:
close_short_with_buyfails withSide::Sell— you accidentally wroteif snapshot.position_size.0 >= 0. Flat positions don't matter here, but using>=makes shorts of0(which don't exist) flip to Sell — and the test forsize = -10would still seesize > 0as false. Re-check the direction.close_flat_has_zero_qtyfails because the function panics — you might have added.abs()instead of.unsigned_abs().i64(0).abs()is fine, but if you wrotei64(-10).abs() as u64you'd risk the i64::MIN footgun from Lesson 4. Stick withunsigned_abs.
Design reflection
Three load-bearing decisions in this lesson:
-
Side is the opposite of position direction — no other case. Long → Sell, Short → Buy. The function doesn't need a third case for "ambiguous" or a fallback for "unknown." The position has a sign or it's flat; the spec inverts the sign or carries zero. A plain inversion of position direction — that's the simplest and most accurate expression of "close (liquidate) this position" in code.
-
close_order_specis side-effect-free even for flat positions. Returning a zero-qty spec instead of filtering inside the function keepsclose_order_spectotal and easy to compose. The Stage 10c scanner canfor snapshot in snapshots { specs.push(close_order_spec(snapshot)); }without branching; the bridge filters at submit time. Pure functions return; impure boundary layers filter. -
The function takes no
mark, noparams. Each compute function owns exactly one concern:margin_healthdecides whether to close;close_order_specdecides how. Mixing them — e.g., takingparamsto apply the liquidation fee to qty — would couple two responsibilities. The fee belongs to Stage 10b (insurance fund), where collateral and fee math live together. Single responsibility makes the bridge's composition path obvious.
Answer key
cd ~/code/openhl-reference
git checkout 22eedf9
diff -u ~/code/my-openhl/crates/liquidation/src/compute.rs ./crates/liquidation/src/compute.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
After Lesson 7:
- compute.rs matches Stage 10a's
compute.rsbyte-for-byte. - lib.rs matches Stage 10a's
lib.rsbyte-for-byte. - Cargo.toml has matched since Lesson 1.
The full Stage 10a crate is now in your workspace.
Common questions
Q1: Should close_order_spec return Option<CloseOrderSpec> for flat positions?
Could, but adds friction. Every caller that doesn't care about the flat case (most of them) would need to .expect("non-flat position") or if let Some(spec) = .... Returning a total CloseOrderSpec with qty == 0 and pushing the filter to the bridge is cheaper for the common case. The Option discipline is great when the empty case is the most common and you want callers forced to handle it; here it's the rare case and forcing handling is overhead.
Q2: Why size > 0 (strict) and not size >= 0 (non-strict) for the Side::Sell branch?
Because flat (size == 0) is neither long nor short — it's outside the long/short dichotomy. The conventions "flat is a long" and "flat is a short" are both arbitrary (a matter of taste); we picked the convention where flat falls into the else branch silently and qty is 0 anyway. Either choice works; the discipline is be consistent and document the choice. The doc says "flat → qty 0, callers filter," which is what readers can verify against the code.
Q3: Could close_order_spec be a method on AccountSnapshot (snapshot.close_order_spec())?
Syntactically yes — impl AccountSnapshot { pub fn close_order_spec(&self) -> CloseOrderSpec { ... } }. We don't do it because the close_order_spec function lives in compute.rs alongside the other margin-math functions; co-locating with the related code beats co-locating with the receiver type. AccountSnapshot is a data carrier (in types.rs); compute lives in compute.rs. The free-function form keeps that separation.
Q4: What if position_size = i64::MIN? Does unsigned_abs handle it?
Yes, by design. i64::MIN.unsigned_abs() == 9_223_372_036_854_775_808u64 (u64::MAX / 2 + 1). The signed i64::MIN.abs() would overflow (it has no positive counterpart in i64); unsigned_abs returns the magnitude as u64, which always has room. This is exactly the Lesson 4 discipline: unsigned_abs for magnitudes, abs only when you're sure the value isn't MIN.
Q5: Why does the test fixture's snapshot function take (size, entry, collateral) rather than (size, entry, mark, collateral) — the function under test takes a snapshot and we typically also need a mark?
close_order_spec takes only the snapshot — no mark. The shared snapshot fixture from Lesson 4 takes the snapshot's three meaningful fields (account is hardcoded) and doesn't carry mark. Mark gets passed as a separate MarkPrice(...) argument to the function under test. The fixture builds what the type needs; the test supplies what the call needs.
Next lesson (Lesson 8) — Stage 10b begins
Lesson 8 starts Stage 10b — the insurance fund. The pure-compute module you finished in Lesson 7 is the what should happen layer. Stage 10b adds the bookkeeping that records what happened — the InsuranceFund state machine that tracks the fund's balance, absorbs deficits from underwater liquidations, and credits liquidation fees from solvent closes. After Stage 10b, the engine knows not just "this account is Liquidatable" but "this close credited 1.5% to the fund" or "this close drained $400 from the fund."
Stage 10b is not yet shipped in openhl as of this lesson's draft — Lesson 8 lands in rethlab when the openhl-side implementation does.
Summary (3 lines)
close_order_spec(snapshot, reason) -> CloseOrderSpec. Side = opposite of position; qty = full size.- Type-safe construction (refuses size = 0). Three tests cover variants.
- Stage 10a complete: types + compute + close_order_spec. Next module: insurance fund (where the crate stops being pure).