FABRKNT
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
純粋な compute
レッスン 8 / 14·CONTENT20 分40 XP
コース
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
レッスンの役割
CONTENT
順序
8 / 14

レッスン7 — close_order_spec — 純粋な compute の最後の関数

問い

Liquidatable なポジションを force-close する。size = +10 の long にはどんな SideQty を emit する? size = −10 の short は? そしてこの関数は markparams を受け取るべきか?

原理(最小モデル)

  • ポジションを 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_specMarkPrice を取らない(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 のインバージョンしかない。MarkPriceLiquidationParams を持ち込むと価格発見や閾値判断の責務が混入する。

失敗例(誤解)

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 する。どんな SideQtysize = −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 には乗らない。Qtyu64 なのは 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 は elseSide::Buy、qty 0 で無害)。markparams も取らない(各関数が 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-liquidation24 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 ともに 22eedf9byte-for-byte 一致。Cargo.toml もレッスン1 以来一致。計算パートのすべてが揃った。

合格基準

cargo test -p openhl-liquidation が 24 pass。よくあるエラー: close_short_with_buySide::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 しやすい。markparams も取らない(単一責任が 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 に切り替わる。