レッスン1 — AdlScore / AdlRecord / AdlReport + adl_score
問い
ADL 候補を「どれだけラッキーに勝ったか」で どうランク付け し、「そもそも候補ではない」を どう型で表す か?
原理(最小モデル)
3 つの設計判断がこのレッスンの核だ。
- score は newtype
AdlScore(i64)。 newtype にするのは、score の意味が ordering であって arithmetic ではないから。PartialOrd + Ordを derive して「比較できる」だけを許し、Add/Mulを derive しないことで「2 つの score を足す/掛ける」という無意味な操作を 禁止 する。Newtype は subtractive — 操作を table から外す。 - 「候補でない」は
Option<AdlScore>で表す。 flat / losing / zero collateral / zero equity の 4 ケースはNone。sentinel(AdlScore(0))を返して caller にチェックさせるのではなく、L2 の orchestration がfilter_mapで不適格を一掃できる。Optionは「この入力からは値が出ない」を型レベルで言う方法。 - score =
pnl_pct × leverage、i128 中間値で計算して i64 に saturate。 両 factor は bps(10000 = 100%)。積は bps² になり病的入力で i64 を overflow するので、i128 で計算 →MARGIN_SCALEで renormalize → 最後だけ saturating cast。
具体例
winner A: collateral 100、long 1 @ entry 100、mark 200(pnl = 100)。
pnl_pct_bps = 100 × 10_000 / 100 = 10_000
leverage_bps = 200 × 10_000 / 200 = 10_000
score = 10_000 × 10_000 / 10_000 = 10_000
collateral だけを 50 に下げた winner B は leverage が上がり、score = 26_666 になる。同じ PnL でも高 leverage = 高 score = 先に haircut。
失敗例(誤解)
「不適格には AdlScore(0) を返せばいい」は誤り。全 caller が「0 = 不適格」か「0 = 適格だが unlucky」を毎回判定させられる。素の i64 を score に使うのも誤り — score_a + score_b のような無意味な操作が silently compile してしまう。不適格は Option の None、score 型は newtype。
ここまでで「なぜ newtype + Option + bps² renormalize か」は着地した。ここから先は、その 3 判断を 実際の adl.rs に組み立てる 深掘りに入る。コードは copy-paste で通る完全形。
🛑 予測。 下の
adl_scoreを読む前に、レッスン0 の 4 トレーダー(A/B/C/D)を score 降順に並べよ。pnl_pct × leverage慣例で。(答えは末尾の答え合わせで検算する。)
ステップで組み立てる
前提
crates/liquidation/ は Liquidation コース(レッスン13)の後の状態。compute.rs / insurance.rs / scanner.rs / types.rs + lib.rs があり 69 テスト pass。本レッスンの diff は 新規ファイル 1 つ(adl.rs)+ lib.rs の 4 行編集 だけ。
Step 1: crates/liquidation/src/adl.rs を新規作成 — module doc + imports
module doc preamble は cargo doc 読者が最初に見る load-bearing な概念(「ADL が orderbook を bypass する理由」)を運ぶ:
//! Auto-deleveraging (ADL) — Layer 3 of the safety-net cascade (ADL参照実装パート).
//!
//! When [`crate::scanner::LiquidationScanner`] finishes a scan with
//! `ScanReport::unfilled_deficit > 0`, the insurance fund couldn't
//! absorb everything. ADL is the last-resort mechanism: rank the
//! profitable counter-positions in the market by a "how much did they
//! win" score, force-close them in descending order, and haircut their
//! unrealized `PnL` until the deficit is absorbed.
//!
//! ### Why ADL bypasses the orderbook
//!
//! If we kept submitting market orders against profitable positions
//! through the matching engine, every order would punch through the
//! bid/ask stack and crash the mark further — which would push more
//! positions underwater. The feedback loop runs away. ADL is designed
//! to **close positions directly in the bookkeeping layer**, never
//! touching the orderbook. The records this module produces carry the
//! [`CloseOrderSpec`] for parity with Liquidation参照実装(計算パート)'s other paths, but the
//! bridge is expected to apply them as account-state mutations rather
//! than CLOB orders.
//!
//! ### How the haircut works
//!
//! Each ADL'd winner had unrealized `PnL` of `P` at the current mark.
//! In a normal close they'd receive `P` in full. With ADL they receive
//! `P - haircut`, where `haircut = min(remaining_deficit, P)`. The
//! system absorbs the `haircut` amount toward the unfilled deficit.
//! Winners with the highest score get the first cut; if the cumulative
//! haircuts reach the deficit before the candidate pool is exhausted,
//! later winners are untouched. If the candidate pool runs out first,
//! `AdlReport::deficit_remaining > 0` and the chain is in genuine
//! unresolved trouble.
//!
//! ### Score
//!
//! Following the Hyperliquid convention, score is
//! `unrealized_pnl_pct × leverage`, expressed in bps²/`MARGIN_SCALE`:
//!
//! ```text
//! pnl_pct_bps = pnl × MARGIN_SCALE / collateral
//! leverage_bps = notional × MARGIN_SCALE / equity
//! score = pnl_pct_bps × leverage_bps / MARGIN_SCALE
//! ```
//!
//! The intuition: the "luckiest" winners are those who both made the
//! highest relative gain AND took the most leveraged risk to get
//! there. They take the haircut first. Stable-sort ties break by
//! `AccountId` ascending so two equally-lucky winners produce a
//! deterministic order across validators.
//!
//! ### Determinism
//!
//! - All arithmetic uses i128 intermediates with saturating-to-i64
//! conversions.
//! - The ranking is a stable sort with a fully-defined tiebreaker.
//! - No clock reads, no `HashMap` iteration.
//!
//! Given the same `(candidates, mark, deficit)`, every validator
//! produces a byte-identical [`AdlReport`].
use crate::compute::{
account_equity, close_order_spec, notional_value, saturate_i128_to_i64, unrealized_pnl,
};
use crate::types::{AccountSnapshot, CloseOrderSpec, MARGIN_SCALE};
use openhl_clob::AccountId;
use openhl_funding::MarkPrice;
要点: module doc は 実装詳細ではなく cascade position から始める。Determinism セクションが 3 つの negative(float なし / HashMap iteration なし / clock read なし)を名指し、将来 Utc::now() を呼ぼうとする contributor を思いとどまらせる。close_order_spec を import しているのは L2 の execute_adl が使うから(L1 では unused 警告が出て L2 で消える)。
Step 2: AdlScore を追加
/// ADL ranking score. Higher means earlier force-close.
///
/// Computed as `pnl_pct × leverage`, both expressed in `MARGIN_SCALE`
/// units; the product is renormalized once. Saturates at `i64::MAX`
/// for pathological inputs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AdlScore(pub i64);
要点: PartialOrd + Ord の derive こそ newtype の存在理由(比較が型の目的のときだけ derive する)。Add/Mul/Sub は derive しない — score の足し算に domain meaning はない。Default は AdlScore(0) =「何も勝っていない」sentinel として意味を持つ。
Step 3: AdlRecord を追加
/// Per-account record of one ADL force-close.
///
/// The bridge applies these as bookkeeping mutations: credit the
/// trader's collateral by `pnl_paid`, set their position size to zero,
/// remove the account from the open-positions table. `close_order`
/// carries the spec for parity with Liquidation参照実装(計算パート)'s other paths and for
/// telemetry; the matching engine is **not** consulted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AdlRecord {
pub account: AccountId,
/// The (notional) close-order spec; emitted for telemetry and shape
/// consistency with [`crate::scanner::LiquidationRecord`]. The
/// bridge does NOT submit this to the CLOB.
pub close_order: CloseOrderSpec,
/// Unrealized `PnL` at the current mark — what the trader would
/// have received in a normal close.
pub pnl_gross: i64,
/// Amount the system kept toward absorbing the deficit
/// (`min(remaining_deficit, pnl_gross)` at the time this record
/// was generated).
pub haircut: i64,
/// What the trader actually receives. Always `pnl_gross - haircut`,
/// always `≥ 0`.
pub pnl_paid: i64,
/// The ranking score at the moment of selection.
pub score: AdlScore,
}
要点: pnl_paid = pnl_gross - haircut は 1 record の保存則 invariant。3 フィールドが同じ情報を 2 回 encode するのは 意図的な冗長性 — audit-trail record は読者に算術を強いない。score: AdlScore(i64 ではない)で「score を balance と比較する」を型が禁じる。
Step 4: AdlReport を追加
/// Summary of one ADL pass.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct AdlReport {
/// One record per ADL'd account, in execution (rank) order.
pub records: Vec<AdlRecord>,
/// Total haircuts applied — how much of the input deficit was
/// absorbed.
pub deficit_absorbed: i64,
/// What the candidate pool couldn't cover. If `> 0`, the chain
/// must halt or the operator must accept the residual as protocol
/// loss.
pub deficit_remaining: i64,
}
要点: ScanReport と同じ shape(vec + i64 aggregate)。Default derive で L2 の「deficit ゼロ」early-return が AdlReport::default() 1 行で書ける。Vec を持つので Copy は付けない。
Step 5: adl_score を追加
/// Compute the ADL score for one account at `mark`.
///
/// Returns `None` for accounts that are not eligible for ADL:
/// - Non-profitable positions (`unrealized_pnl ≤ 0`).
/// - Flat positions (`position_size == 0`).
/// - Accounts whose collateral or equity is zero (degenerate;
/// score's divisor would be zero or negative).
#[must_use]
pub fn adl_score(snapshot: &AccountSnapshot, mark: MarkPrice) -> Option<AdlScore> {
if snapshot.position_size.0 == 0 {
return None;
}
let pnl = unrealized_pnl(snapshot, mark);
if pnl <= 0 {
return None;
}
let collateral = snapshot.collateral.0;
if collateral <= 0 {
return None;
}
let equity = account_equity(snapshot, mark);
if equity <= 0 {
return None;
}
let notional = notional_value(snapshot, mark);
// pnl_pct_bps = pnl × MARGIN_SCALE / collateral
let pnl_pct = i128::from(pnl).saturating_mul(i128::from(MARGIN_SCALE))
/ i128::from(collateral);
// leverage_bps = notional × MARGIN_SCALE / equity
let leverage = i128::from(notional).saturating_mul(i128::from(MARGIN_SCALE))
/ i128::from(equity);
// score = pnl_pct × leverage / MARGIN_SCALE (renormalize)
let raw = pnl_pct.saturating_mul(leverage) / i128::from(MARGIN_SCALE);
Some(AdlScore(saturate_i128_to_i64(raw)))
}
要点: 4 つの early-return guard はコスト昇順(flat 比較 → pnl → collateral → equity)。すべて <= 0(< 0 ではない)— ゼロは候補状態ではない。全 arithmetic で i128 中間値 + saturating、最後の saturate_i128_to_i64 だけが width-narrowing で情報を失いうる。division は正-正なので素の /(overflow しえない)。tunable knob がないので LiquidationParams は取らない(未使用 parameter を pre-add しない)。
Step 6: 5 個の unit test を追加(adl.rs 末尾に #[cfg(test)] mod tests)
#[cfg(test)]
mod tests {
use super::*;
use openhl_funding::{Notional, PositionSize};
use proptest::prelude::*;
fn snapshot(account: u64, size: i64, entry: u64, collateral: i64) -> AccountSnapshot {
AccountSnapshot {
account: AccountId(account),
position_size: PositionSize(size),
avg_entry: MarkPrice(entry),
collateral: Notional(collateral),
}
}
// ─── adl_score: None cases ─────────────────────────────────────
#[test]
fn score_none_for_flat_position() {
let s = snapshot(1, 0, 100, 1_000);
assert_eq!(adl_score(&s, MarkPrice(100)), None);
}
#[test]
fn score_none_for_losing_long() {
// Long 1 @ 100, mark 80 → pnl = -20 → not eligible
let s = snapshot(1, 1, 100, 1_000);
assert_eq!(adl_score(&s, MarkPrice(80)), None);
}
#[test]
fn score_none_for_short_at_entry() {
// pnl = 0, not profitable.
let s = snapshot(1, -1, 100, 1_000);
assert_eq!(adl_score(&s, MarkPrice(100)), None);
}
#[test]
fn score_none_for_zero_collateral() {
let s = snapshot(1, 1, 100, 0);
// Even if profitable at mark 120, collateral = 0 makes pnl_pct
// undefined (divide by zero) → ineligible.
assert_eq!(adl_score(&s, MarkPrice(120)), None);
}
// ─── adl_score: ordering ───────────────────────────────────────
#[test]
fn score_higher_for_higher_leverage_winner() {
// Two profitable longs with the same pnl_pct but different
// leverage. Higher leverage → higher score.
// Long 1 @ entry 100, mark 200 → pnl = 100.
// A: collateral 100, equity = 100 + 100 = 200, notional = 200, leverage = 1×
// pnl_pct_bps = 100 × 10_000 / 100 = 10_000
// leverage_bps = 200 × 10_000 / 200 = 10_000
// score = 10_000 × 10_000 / 10_000 = 10_000
// B: collateral 50, equity = 50 + 100 = 150, notional = 200, leverage = ~1.33×
// pnl_pct_bps = 100 × 10_000 / 50 = 20_000
// leverage_bps = 200 × 10_000 / 150 = 13_333
// score = 20_000 × 13_333 / 10_000 = 26_666
let a = snapshot(1, 1, 100, 100);
let b = snapshot(2, 1, 100, 50);
let sa = adl_score(&a, MarkPrice(200)).unwrap();
let sb = adl_score(&b, MarkPrice(200)).unwrap();
assert!(sb > sa, "higher leverage winner should rank above lower");
}
}
要点: 4 つの None テストが eligibility filter の各 branch を、ordering テストが score の唯一の load-bearing 性質(relative magnitude)を exercise する。ordering は assert!(sb > sa)(assert_eq! ではない)— 正確な値は rounding に fragile だが ordering は不変。コメントの math-walk が各テストを worked example に変える。proptest::prelude::* はレッスン4 用に forward-staged。
Step 7: lib.rs を配線(3 編集)
pub mod adl; を alphabetical に挿入し、再 export を 1 行追加:
pub mod adl;
pub mod compute;
pub mod insurance;
pub mod scanner;
pub mod types;
pub use adl::{adl_score, AdlRecord, AdlReport, AdlScore};
pub use compute::{
account_equity, close_order_spec, liquidation_fee, margin_health, margin_ratio,
notional_value, solvent_close_outcome, underwater_close_outcome, unrealized_pnl,
};
pub use insurance::{InsuranceFund, WithdrawOutcome};
pub use scanner::{CloseOutcomeKind, LiquidationRecord, LiquidationScanner, ScanReport};
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, SolventClose,
UnderwaterClose, MARGIN_SCALE,
};
答え合わせ
cd ~/code/openhl-reference && git checkout d66b44a
diff -u ~/code/my-openhl/crates/liquidation/src/adl.rs ./crates/liquidation/src/adl.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
レッスン1 後、adl.rs は参照実装の score_higher_for_higher_leverage_winner テストまで一致(execute_adl と残り 16 テストは L2/L3/L4)。lib.rs は pub mod adl; + 再 export についてバイト単位で一致。
予測の検算: 4 トレーダーの正確な順は B → A → D → C(B が最高 leverage の profitable winner)。
合格基準
cargo test -p openhl-liquidation
→ 74 テスト pass(Liquidation 由来 69 + 新規 ADL score 5)。
落ちる主因: import から 5 名のいずれか欠落 / pnl <= 0 を pnl < 0 と書いて score_none_for_short_at_entry が落ちる / division 順を pnl × (MARGIN_SCALE / collateral) と書いて truncation で ordering が flip。
まとめ(3行)
- score は newtype
AdlScore(i64)— ordering を enable し arithmetic を forbid する。 - 不適格は
OptionのNone(sentinel ではない)で、L2 がfilter_mapで一掃できる。 - score =
pnl_pct × leverage、i128 中間値で計算し最後だけ i64 に saturate。