Skip to main content

nautilus_hyperliquid/
outcome_settlement.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Runtime dispatch for HIP-4 outcome settlements.
17//!
18//! Each periodic `outcomeMeta` poll calls
19//! [`crate::http::parse::derive_outcome_settlements`] to identify settled
20//! (outcome_index, outcome_side) pairs, then materializes position-closing
21//! [`FillReport`]s for any spot balance still holding a settled side token.
22//! Settlements settle in USDH at 0 (losing) or 1 (winning), so each closing
23//! fill carries a USDH commission of zero.
24//!
25//! State is tracked in [`OutcomeSettlementTracker`]; once a pair has been
26//! dispatched it is not re-emitted on subsequent polls.
27
28use ahash::AHashSet;
29use nautilus_core::{UUID4, UnixNanos};
30use nautilus_model::{
31    enums::{LiquiditySide, OrderSide},
32    identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
33    reports::FillReport,
34    types::{Currency, Money, Price, Quantity},
35};
36use rust_decimal::Decimal;
37
38use crate::{
39    common::{converters::outcome_asset_id_to_instrument_id, types::HyperliquidAssetId},
40    http::{
41        models::SpotClearinghouseState,
42        parse::{
43            OUTCOME_PRICE_DECIMALS, OUTCOME_SIZE_DECIMALS, OutcomeSettlement, get_usdh_currency,
44        },
45    },
46};
47
48/// Tracks `(outcome_index, outcome_side)` pairs already dispatched so repeat
49/// polls do not re-emit settlement fills.
50#[derive(Debug, Default)]
51pub struct OutcomeSettlementTracker {
52    processed: AHashSet<(u32, u8)>,
53}
54
55impl OutcomeSettlementTracker {
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    #[must_use]
62    pub fn len(&self) -> usize {
63        self.processed.len()
64    }
65
66    #[must_use]
67    pub fn is_empty(&self) -> bool {
68        self.processed.is_empty()
69    }
70
71    #[must_use]
72    pub fn contains(&self, outcome_index: u32, outcome_side: u8) -> bool {
73        self.processed.contains(&(outcome_index, outcome_side))
74    }
75
76    fn mark(&mut self, outcome_index: u32, outcome_side: u8) -> bool {
77        self.processed.insert((outcome_index, outcome_side))
78    }
79}
80
81/// Materializes one closing [`FillReport`] per held outcome side token that
82/// has newly settled.
83///
84/// `settlements` is the output of `derive_outcome_settlements`. Each
85/// settlement is matched to a non-zero spot balance keyed by the `+E` token
86/// name (Hyperliquid's spot-balance convention for outcome side tokens). Pairs
87/// already in `tracker` are skipped; pairs that emit a fill are recorded.
88///
89/// Returns the synthetic fills ready to be forwarded through the execution
90/// emitter. Returns an empty vector when no held outcome side token has
91/// settled this round.
92#[must_use]
93pub fn build_settlement_fills(
94    settlements: &[OutcomeSettlement],
95    spot_state: &SpotClearinghouseState,
96    tracker: &mut OutcomeSettlementTracker,
97    account_id: AccountId,
98    ts: UnixNanos,
99) -> Vec<FillReport> {
100    if settlements.is_empty() {
101        return Vec::new();
102    }
103
104    let usdh = get_usdh_currency();
105    let mut fills = Vec::new();
106
107    for settlement in settlements {
108        if tracker.contains(settlement.outcome_index, settlement.outcome_side) {
109            continue;
110        }
111
112        let asset_id =
113            HyperliquidAssetId::outcome(settlement.outcome_index, settlement.outcome_side);
114        let Some(encoding) = asset_id.outcome_encoding() else {
115            continue;
116        };
117        let token_coin = format!("+{encoding}");
118
119        let Some(balance) = spot_state
120            .balances
121            .iter()
122            .find(|b| b.coin.as_str() == token_coin && !b.total.is_zero())
123        else {
124            // No held position; mark processed so it does not re-trigger
125            // on subsequent polls.
126            tracker.mark(settlement.outcome_index, settlement.outcome_side);
127            continue;
128        };
129
130        let instrument_id = match outcome_asset_id_to_instrument_id(asset_id) {
131            Ok(id) => id,
132            Err(e) => {
133                log::warn!("Outcome settlement skipped, instrument id resolution failed: {e}",);
134                continue;
135            }
136        };
137
138        if let Some(fill) = build_close_fill(
139            instrument_id,
140            account_id,
141            settlement,
142            balance.total,
143            usdh,
144            ts,
145        ) {
146            fills.push(fill);
147            tracker.mark(settlement.outcome_index, settlement.outcome_side);
148        }
149    }
150
151    fills
152}
153
154fn build_close_fill(
155    instrument_id: InstrumentId,
156    account_id: AccountId,
157    settlement: &OutcomeSettlement,
158    quantity: Decimal,
159    currency: Currency,
160    ts: UnixNanos,
161) -> Option<FillReport> {
162    let qty = Quantity::from_decimal_dp(quantity, OUTCOME_SIZE_DECIMALS as u8).ok()?;
163    let price = Price::from_decimal_dp(
164        Decimal::from(settlement.final_value),
165        OUTCOME_PRICE_DECIMALS as u8,
166    )
167    .ok()?;
168
169    // Deterministic identifiers so duplicate poll dispatch (e.g. process
170    // restart with persisted tracker missing) does not yield distinct
171    // trade ids for the same settlement.
172    let tag = format!(
173        "SETTLE-{}-{}",
174        settlement.outcome_index, settlement.outcome_side,
175    );
176    let venue_order_id = VenueOrderId::new(format!("HYPERLIQUID-{tag}"));
177    let trade_id = TradeId::new(format!("HYPERLIQUID-{tag}-{}", settlement.final_value));
178
179    Some(FillReport::new(
180        account_id,
181        instrument_id,
182        venue_order_id,
183        trade_id,
184        OrderSide::Sell,
185        qty,
186        price,
187        Money::zero(currency),
188        LiquiditySide::NoLiquiditySide,
189        None,
190        None,
191        ts,
192        ts,
193        Some(UUID4::new()),
194    ))
195}
196
197#[cfg(test)]
198mod tests {
199    use rstest::rstest;
200    use rust_decimal::Decimal;
201    use rust_decimal_macros::dec;
202    use ustr::Ustr;
203
204    use super::*;
205    use crate::http::models::SpotBalance;
206
207    fn account() -> AccountId {
208        AccountId::new("HYPERLIQUID-001")
209    }
210
211    fn spot_state_with(coin: &str, total: Decimal) -> SpotClearinghouseState {
212        SpotClearinghouseState {
213            balances: vec![SpotBalance {
214                coin: Ustr::from(coin),
215                token: None,
216                total,
217                hold: Decimal::ZERO,
218                entry_ntl: None,
219            }],
220        }
221    }
222
223    #[rstest]
224    fn empty_settlements_emit_nothing() {
225        let mut tracker = OutcomeSettlementTracker::new();
226        let state = SpotClearinghouseState::default();
227        let fills =
228            build_settlement_fills(&[], &state, &mut tracker, account(), UnixNanos::default());
229        assert!(fills.is_empty());
230        assert!(tracker.is_empty());
231    }
232
233    #[rstest]
234    fn winning_side_emits_close_at_one_usdh() {
235        let settlement = OutcomeSettlement {
236            outcome_index: 1,
237            outcome_side: 0,
238            final_value: 1,
239        };
240        let state = spot_state_with("+10", dec!(25));
241        let mut tracker = OutcomeSettlementTracker::new();
242
243        let fills = build_settlement_fills(
244            &[settlement],
245            &state,
246            &mut tracker,
247            account(),
248            UnixNanos::default(),
249        );
250
251        assert_eq!(fills.len(), 1);
252        let fill = &fills[0];
253        assert_eq!(
254            fill.instrument_id,
255            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID"),
256        );
257        assert_eq!(fill.order_side, OrderSide::Sell);
258        assert_eq!(fill.last_qty.as_decimal(), dec!(25));
259        // Quantity must match the outcome instrument's size precision (2),
260        // not the USDH settlement currency precision (8)
261        assert_eq!(fill.last_qty.precision, 2);
262        assert_eq!(fill.last_px.as_decimal(), dec!(1));
263        assert_eq!(fill.last_px.precision, 4);
264        assert_eq!(fill.commission.currency.code.as_str(), "USDH");
265        assert!(fill.commission.as_decimal().is_zero());
266        assert!(tracker.contains(1, 0));
267    }
268
269    #[rstest]
270    fn fractional_balance_rounds_to_outcome_size_precision() {
271        // 25.1234567 should land at 25.12 once snapped to OUTCOME_SIZE_DECIMALS (2)
272        let settlement = OutcomeSettlement {
273            outcome_index: 4,
274            outcome_side: 0,
275            final_value: 1,
276        };
277        let state = spot_state_with("+40", dec!(25.1234567));
278        let mut tracker = OutcomeSettlementTracker::new();
279
280        let fills = build_settlement_fills(
281            &[settlement],
282            &state,
283            &mut tracker,
284            account(),
285            UnixNanos::default(),
286        );
287
288        assert_eq!(fills.len(), 1);
289        assert_eq!(fills[0].last_qty.precision, 2);
290        assert_eq!(fills[0].last_qty.as_decimal(), dec!(25.12));
291    }
292
293    #[rstest]
294    fn losing_side_emits_close_at_zero_usdh() {
295        let settlement = OutcomeSettlement {
296            outcome_index: 1,
297            outcome_side: 1,
298            final_value: 0,
299        };
300        let state = spot_state_with("+11", dec!(10));
301        let mut tracker = OutcomeSettlementTracker::new();
302
303        let fills = build_settlement_fills(
304            &[settlement],
305            &state,
306            &mut tracker,
307            account(),
308            UnixNanos::default(),
309        );
310
311        assert_eq!(fills.len(), 1);
312        let fill = &fills[0];
313        assert_eq!(
314            fill.instrument_id,
315            InstrumentId::from("1-NO-OUTCOME.HYPERLIQUID"),
316        );
317        assert_eq!(fill.last_px.as_decimal(), dec!(0));
318        assert!(tracker.contains(1, 1));
319    }
320
321    #[rstest]
322    fn unheld_settlement_marks_tracker_without_fill() {
323        let settlement = OutcomeSettlement {
324            outcome_index: 5,
325            outcome_side: 0,
326            final_value: 1,
327        };
328        let state = SpotClearinghouseState::default();
329        let mut tracker = OutcomeSettlementTracker::new();
330
331        let fills = build_settlement_fills(
332            &[settlement],
333            &state,
334            &mut tracker,
335            account(),
336            UnixNanos::default(),
337        );
338
339        assert!(fills.is_empty());
340        // Tracker still records the settlement so subsequent polls skip it
341        assert!(tracker.contains(5, 0));
342    }
343
344    #[rstest]
345    fn zero_balance_skipped_and_marked() {
346        let settlement = OutcomeSettlement {
347            outcome_index: 7,
348            outcome_side: 0,
349            final_value: 1,
350        };
351        let state = spot_state_with("+70", Decimal::ZERO);
352        let mut tracker = OutcomeSettlementTracker::new();
353
354        let fills = build_settlement_fills(
355            &[settlement],
356            &state,
357            &mut tracker,
358            account(),
359            UnixNanos::default(),
360        );
361
362        assert!(fills.is_empty());
363        assert!(tracker.contains(7, 0));
364    }
365
366    #[rstest]
367    fn repeated_settlement_is_idempotent() {
368        let settlement = OutcomeSettlement {
369            outcome_index: 2,
370            outcome_side: 0,
371            final_value: 1,
372        };
373        let state = spot_state_with("+20", dec!(5));
374        let mut tracker = OutcomeSettlementTracker::new();
375
376        let first = build_settlement_fills(
377            &[settlement],
378            &state,
379            &mut tracker,
380            account(),
381            UnixNanos::default(),
382        );
383        let second = build_settlement_fills(
384            &[settlement],
385            &state,
386            &mut tracker,
387            account(),
388            UnixNanos::default(),
389        );
390
391        assert_eq!(first.len(), 1);
392        assert!(second.is_empty(), "repeat dispatch must not re-emit fills");
393    }
394
395    #[rstest]
396    fn deterministic_identifiers_per_settlement() {
397        let settlement = OutcomeSettlement {
398            outcome_index: 3,
399            outcome_side: 1,
400            final_value: 0,
401        };
402        let state = spot_state_with("+31", dec!(1));
403        let mut tracker = OutcomeSettlementTracker::new();
404        let fills = build_settlement_fills(
405            &[settlement],
406            &state,
407            &mut tracker,
408            account(),
409            UnixNanos::default(),
410        );
411        let fill = &fills[0];
412        assert_eq!(fill.venue_order_id.as_str(), "HYPERLIQUID-SETTLE-3-1");
413        assert_eq!(fill.trade_id.as_str(), "HYPERLIQUID-SETTLE-3-1-0");
414    }
415}