レッスン7 — close_order_spec — 純粋な compute の最後の関数
問い
Liquidatable なポジションを force-close する。size = +10 の long にはどんな Side と Qty を emit する? size = −10 の short は? そしてこの関数は mark や params を受け取るべきか?
原理(最小モデル)
- ポジションを close する基本ルール: side は常に反対。 long は売って close、short は買って close。エンジンは side を 決めず 反転させるだけ。
- public 境界での
unsigned_abs。 レッスン4 の規律が bridge と会話する関数で表に出る。出力Qty(u64)は CLOB が期待する型 — エンジンは符号変換を自分の境界で行う。 - flat ポジションをフィルタしない。 flat は
qty == 0の spec を生成、bridge が submit 前にフィルタ。close_order_specを total かつ side-effect-free に保つと scanner と compose しやすい。 - 単一責任のスコープ。
close_order_specはMarkPriceを取らない(market order に price なし)しLiquidationParamsも取らない(liquidate するか否かはmargin_healthの仕事)。snapshot 1 つ入れて spec 1 つ出す。
具体例
CLOB と liquidation engine の橋:
保有ポジション close_order_spec が emit する反対方向の市場注文
Long size = +10 ──[反転]──► Side::Sell qty = 10 → CLOB に「10 売り」、板の bid を食って flat
Short size = −10 ──[反転]──► Side::Buy qty = 10 → CLOB に「10 買い」、板の ask を食って flat
Flat size = 0 ──[反転]──► Side::Buy qty = 0 → bridge がフィルタして submit せず
この関数が決めるのは「方向反転」「magnitude を unsigned_abs で取り出す」の 2 つだけ:
・liquidate するかは レッスン6 margin_health が決定済み / いくらで close は CLOB の板が決定
・flat の spec を出さないフィルタは Bridge が行う — 各レイヤーがちょうど 1 つの関心事
本質は side のインバージョンしかない。MarkPrice や LiquidationParams を持ち込むと価格発見や閾値判断の責務が混入する。
失敗例(誤解)
「if size >= 0 { Sell } else { Buy }(flat を Sell に)でいい」は誤り、問題 3 つ: (1) flat-as-Sell は挙動の選択で、pure compute でなく bridge に属する。(2) 現在の > 0 は「flat は long でも short でもない」を正しく反映。(3) qty == 0 + Side::Sell の本番セマンティクスは matching engine で未定義(bridge がどのみちフィルタ)。エッジケースを隠す慣例でなく、最もクリーンな契約を提供する慣例を選ぶ。
ここまでで「side 反転 + unsigned_abs」は着地した。ここで compute を閉じる(関数は 11 行、存在理由は side-inversion ルールのロックと pure-compute 完成のマーク)。コードは完全形。
🛑 予測。
size = 10の long を force-close する。どんなSideとQty?size = −10の short は?(答え: long →Side::Sell,Qty(10)、short →Side::Buy,Qty(10)。long は売って close(10 long 保有 → 10 売って flat)、short は買って close。quantity は常に position の magnitude、符号は side が運び qty には乗らない。Qtyがu64なのは magnitude が符号を持たないから。)
ステップで組み立てる
Step 1: close_order_spec を追記
/// 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),
}
}
side は常に反対(long→Sell, short→Buy — matching engine は close の 意図 を知らず side が乗った order と見えるだけ)。unsigned_abs() が magnitude を u64 で(符号変換を境界で 1 度)。> 0 strict(flat は else で Side::Buy、qty 0 で無害)。mark も params も取らない(各関数が 1 つの関心事)。Option でなく値を返す(total — flat でも spec を返す、scanner が filter_map なしに均質に処理できる)。
Step 2: 3 つの unit test を追加
// ─── 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));
}
close_long_with_sell は 3 出力フィールド全て assert(bridge が 3 つに依存)。close_short_with_buy は account をスキップ(同じ入力経路、long で動けば short でも)。close_flat_has_zero_qty は関数がフィルタ しない 契約を文書化(将来フィルタを足したら失敗する)。
Step 3: lib.rs を更新
pub use compute::{
account_equity, close_order_spec, margin_health, margin_ratio, notional_value, unrealized_pnl,
};
6 つの compute 関数すべて re-export 完了。
Step 4: テスト実行
cargo test -p openhl-liquidation が 24 pass(L4-6 の 21 + close 3)。compute(計算パート)が 22eedf9 に byte-for-byte 完成。
答え合わせ
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
git checkout main
compute.rs / lib.rs ともに 22eedf9 と byte-for-byte 一致。Cargo.toml もレッスン1 以来一致。計算パートのすべてが揃った。
合格基準
cargo test -p openhl-liquidation が 24 pass。よくあるエラー: close_short_with_buy が Side::Sell で失敗(>= 0 と書いた)/ close_flat_has_zero_qty が panic(.abs() を使った — unsigned_abs で通す)。
まとめ(3行)
- side はポジション方向の反対(long→Sell, short→Buy)— 「曖昧」「不明」の分岐もフォールバックもない。ポジション方向の単純な反転が「close する」を最もシンプルに表現する。
- flat に対しても side-effect-free(zero-qty spec を返す、bridge がフィルタ)— total で compose しやすい。
markもparamsも取らない(単一責任が bridge の composition path を明白にする)。 - public 境界の
unsigned_absが符号変換を 1 度だけ行う。24 tests で計算パートが22eedf9に完成。
次のレッスン(レッスン8)— Insurance fund(保険基金パート)が始まる
InsuranceFund state machine が入る。計算パートが 何が起きるべきか なら、保険基金パートは 何が起きたかを記録する帳簿。fund の balance を track し、underwater liquidation の不足を吸収し、solvent な close から fee を credit する。SHA は 260883b に切り替わる。