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 close at 0 (losing) or 1 (winning), with zero commission in
23//! the outcome instrument's quote currency.
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        client::HyperliquidHttpClient,
42        models::{OutcomeMeta, SpotClearinghouseState},
43        parse::{OUTCOME_PRICE_DECIMALS, OUTCOME_SIZE_DECIMALS, OutcomeSettlement},
44    },
45};
46
47/// Tracks `(outcome_index, outcome_side)` pairs already dispatched so repeat
48/// polls do not re-emit settlement fills.
49#[derive(Debug, Default)]
50pub struct OutcomeSettlementTracker {
51    processed: AHashSet<(u32, u8)>,
52}
53
54impl OutcomeSettlementTracker {
55    #[must_use]
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    #[must_use]
61    pub fn len(&self) -> usize {
62        self.processed.len()
63    }
64
65    #[must_use]
66    pub fn is_empty(&self) -> bool {
67        self.processed.is_empty()
68    }
69
70    #[must_use]
71    pub fn contains(&self, outcome_index: u32, outcome_side: u8) -> bool {
72        self.processed.contains(&(outcome_index, outcome_side))
73    }
74
75    fn mark(&mut self, outcome_index: u32, outcome_side: u8) -> bool {
76        self.processed.insert((outcome_index, outcome_side))
77    }
78}
79
80/// Materializes one closing [`FillReport`] per held outcome side token that
81/// has newly settled.
82///
83/// `settlements` is the output of `derive_outcome_settlements`. Each
84/// settlement is matched to a non-zero spot balance keyed by the `+E` token
85/// name (Hyperliquid's spot-balance convention for outcome side tokens). Pairs
86/// already in `tracker` are skipped; pairs that emit a fill are recorded.
87/// Commission currency comes from the cached instrument, then `meta`, with
88/// USDC as the fallback when the quote token is unavailable.
89///
90/// Returns the synthetic fills ready to be forwarded through the execution
91/// emitter. Returns an empty vector when no held outcome side token has
92/// settled this round.
93#[must_use]
94pub fn build_settlement_fills(
95    settlements: &[OutcomeSettlement],
96    spot_state: &SpotClearinghouseState,
97    meta: &OutcomeMeta,
98    http_client: &HyperliquidHttpClient,
99    tracker: &mut OutcomeSettlementTracker,
100    account_id: AccountId,
101    ts_init: UnixNanos,
102) -> Vec<FillReport> {
103    if settlements.is_empty() {
104        return Vec::new();
105    }
106
107    let mut fills = Vec::new();
108
109    for settlement in settlements {
110        if tracker.contains(settlement.outcome_index, settlement.outcome_side) {
111            continue;
112        }
113
114        let asset_id =
115            HyperliquidAssetId::outcome(settlement.outcome_index, settlement.outcome_side);
116        let Some(encoding) = asset_id.outcome_encoding() else {
117            continue;
118        };
119        let token_coin = format!("+{encoding}");
120
121        let Some(balance) = spot_state
122            .balances
123            .iter()
124            .find(|b| b.coin == token_coin && !b.total.is_zero())
125        else {
126            // No held position; mark processed so it does not re-trigger
127            // on subsequent polls.
128            tracker.mark(settlement.outcome_index, settlement.outcome_side);
129            continue;
130        };
131
132        let instrument_id = match outcome_asset_id_to_instrument_id(asset_id) {
133            Ok(id) => id,
134            Err(e) => {
135                log::warn!("Outcome settlement skipped, instrument id resolution failed: {e}",);
136                continue;
137            }
138        };
139
140        if let Some(fill) = build_close_fill(
141            instrument_id,
142            account_id,
143            settlement,
144            balance.total,
145            http_client.outcome_quote_currency(instrument_id, settlement.outcome_index, meta),
146            ts_init,
147        ) {
148            fills.push(fill);
149            tracker.mark(settlement.outcome_index, settlement.outcome_side);
150        }
151    }
152
153    fills
154}
155
156fn build_close_fill(
157    instrument_id: InstrumentId,
158    account_id: AccountId,
159    settlement: &OutcomeSettlement,
160    quantity: Decimal,
161    currency: Currency,
162    ts_init: UnixNanos,
163) -> Option<FillReport> {
164    let qty = Quantity::from_decimal_dp(quantity, OUTCOME_SIZE_DECIMALS as u8).ok()?;
165    let price = Price::from_decimal_dp(
166        Decimal::from(settlement.final_value),
167        OUTCOME_PRICE_DECIMALS as u8,
168    )
169    .ok()?;
170
171    // Deterministic identifiers so duplicate poll dispatch (e.g. process
172    // restart with persisted tracker missing) does not yield distinct
173    // trade ids for the same settlement.
174    let tag = format!(
175        "SETTLE-{}-{}",
176        settlement.outcome_index, settlement.outcome_side,
177    );
178    let venue_order_id = VenueOrderId::new(format!("HYPERLIQUID-{tag}"));
179    let trade_id = TradeId::new(format!("HYPERLIQUID-{tag}-{}", settlement.final_value));
180
181    Some(FillReport::new(
182        account_id,
183        instrument_id,
184        venue_order_id,
185        trade_id,
186        OrderSide::Sell,
187        qty,
188        price,
189        Money::zero(currency),
190        LiquiditySide::NoLiquiditySide,
191        None,
192        None,
193        ts_init,
194        ts_init,
195        Some(UUID4::new()),
196    ))
197}
198
199#[cfg(test)]
200mod tests {
201    use nautilus_model::instruments::Instrument;
202    use rstest::rstest;
203    use rust_decimal::Decimal;
204    use rust_decimal_macros::dec;
205    use ustr::Ustr;
206
207    use super::*;
208    use crate::{
209        common::enums::HyperliquidEnvironment,
210        http::{
211            models::SpotBalance,
212            parse::{create_instrument_from_def, get_usdh_currency, parse_outcome_instruments},
213        },
214    };
215
216    fn client() -> HyperliquidHttpClient {
217        HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap()
218    }
219
220    fn meta() -> OutcomeMeta {
221        serde_json::from_str(include_str!("../test_data/http_outcome_meta.json")).unwrap()
222    }
223
224    fn account() -> AccountId {
225        AccountId::new("HYPERLIQUID-001")
226    }
227
228    fn spot_state_with(coin: &str, total: Decimal) -> SpotClearinghouseState {
229        SpotClearinghouseState {
230            balances: vec![SpotBalance {
231                coin: Ustr::from(coin),
232                token: None,
233                total,
234                hold: Decimal::ZERO,
235                entry_ntl: None,
236            }],
237        }
238    }
239
240    #[rstest]
241    #[case::usdc_winner(1, 0, 1, Currency::USDC())]
242    #[case::usdc_loser(1, 1, 0, Currency::USDC())]
243    #[case::usdh_winner(2, 1, 1, get_usdh_currency())]
244    #[case::usdh_loser(2, 0, 0, get_usdh_currency())]
245    #[case::missing_quote(3, 0, 1, Currency::USDC())]
246    #[case::unlisted(20, 1, 0, Currency::USDC())]
247    fn settlement_fill_uses_outcome_quote_currency(
248        #[case] outcome_index: u32,
249        #[case] outcome_side: u8,
250        #[case] final_value: u8,
251        #[case] currency: Currency,
252    ) {
253        let settlement = OutcomeSettlement {
254            outcome_index,
255            outcome_side,
256            final_value,
257        };
258
259        let encoding = HyperliquidAssetId::outcome(outcome_index, outcome_side)
260            .outcome_encoding()
261            .unwrap();
262        let state = spot_state_with(&format!("+{encoding}"), dec!(37));
263        let mut tracker = OutcomeSettlementTracker::new();
264        let fills = build_settlement_fills(
265            &[settlement],
266            &state,
267            &meta(),
268            &client(),
269            &mut tracker,
270            account(),
271            UnixNanos::new(123),
272        );
273
274        assert_eq!(fills.len(), 1);
275        let fill = &fills[0];
276        assert_eq!(
277            fill.instrument_id,
278            outcome_asset_id_to_instrument_id(HyperliquidAssetId::outcome(
279                outcome_index,
280                outcome_side
281            ))
282            .unwrap()
283        );
284        assert_eq!(fill.account_id, account());
285        assert_eq!(fill.order_side, OrderSide::Sell);
286        assert_eq!(fill.last_qty, Quantity::from("37"));
287        assert_eq!(
288            fill.last_px,
289            Price::from_decimal_dp(Decimal::from(final_value), OUTCOME_PRICE_DECIMALS as u8)
290                .unwrap()
291        );
292        assert_eq!(fill.commission, Money::zero(currency));
293        assert_eq!(fill.liquidity_side, LiquiditySide::NoLiquiditySide);
294        assert_eq!(fill.avg_px, None);
295        assert_eq!(fill.client_order_id, None);
296        assert_eq!(fill.venue_position_id, None);
297        assert_eq!(
298            fill.venue_order_id,
299            VenueOrderId::new(format!("HYPERLIQUID-SETTLE-{outcome_index}-{outcome_side}"))
300        );
301        assert_eq!(
302            fill.trade_id,
303            TradeId::new(format!(
304                "HYPERLIQUID-SETTLE-{outcome_index}-{outcome_side}-{final_value}"
305            ))
306        );
307        assert_eq!(fill.ts_event, UnixNanos::new(123));
308        assert_eq!(fill.ts_init, UnixNanos::new(123));
309        assert_eq!(tracker.len(), 1);
310    }
311
312    #[rstest]
313    fn settlement_fills_preserve_mixed_quote_currencies() {
314        let settlements = [
315            OutcomeSettlement {
316                outcome_index: 1,
317                outcome_side: 0,
318                final_value: 1,
319            },
320            OutcomeSettlement {
321                outcome_index: 2,
322                outcome_side: 0,
323                final_value: 0,
324            },
325        ];
326
327        let mut state = spot_state_with("+10", dec!(12));
328        state
329            .balances
330            .extend(spot_state_with("+20", dec!(56)).balances);
331        let mut tracker = OutcomeSettlementTracker::new();
332        let fills = build_settlement_fills(
333            &settlements,
334            &state,
335            &meta(),
336            &client(),
337            &mut tracker,
338            account(),
339            UnixNanos::default(),
340        );
341
342        assert_eq!(fills.len(), 2);
343        assert_eq!(
344            fills[0].instrument_id,
345            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID")
346        );
347        assert_eq!(fills[0].last_qty, Quantity::from("12"));
348        assert_eq!(fills[0].last_px, Price::from("1.0000"));
349        assert_eq!(fills[0].commission, Money::zero(Currency::USDC()));
350        assert_eq!(
351            fills[1].instrument_id,
352            InstrumentId::from("2-YES-OUTCOME.HYPERLIQUID")
353        );
354        assert_eq!(fills[1].last_qty, Quantity::from("56"));
355        assert_eq!(fills[1].last_px, Price::from("0.0000"));
356        assert_eq!(fills[1].commission, Money::zero(get_usdh_currency()));
357        assert_eq!(tracker.len(), 2);
358    }
359
360    #[rstest]
361    fn settlement_fill_uses_replaced_cached_currency() {
362        let client = client();
363        let mut defs = parse_outcome_instruments(&meta()).unwrap();
364        let original = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
365        client.cache_instrument(&original);
366        defs[2].quote = "USDC".into();
367        let replacement = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
368        client.cache_instrument(&replacement);
369
370        let settlement = OutcomeSettlement {
371            outcome_index: 2,
372            outcome_side: 0,
373            final_value: 1,
374        };
375
376        let mut tracker = OutcomeSettlementTracker::new();
377        let fills = build_settlement_fills(
378            &[settlement],
379            &spot_state_with("+20", dec!(9.87)),
380            &meta(),
381            &client,
382            &mut tracker,
383            account(),
384            UnixNanos::default(),
385        );
386
387        assert_eq!(fills.len(), 1);
388        assert_eq!(fills[0].instrument_id, replacement.id());
389        assert_eq!(fills[0].commission, Money::zero(Currency::USDC()));
390    }
391
392    #[rstest]
393    #[case::missing_outcome(false)]
394    #[case::different_quote(true)]
395    fn settlement_fill_preserves_cached_currency(#[case] listed: bool) {
396        let client = client();
397        let original = meta();
398        let mut snapshot = meta();
399
400        if listed {
401            snapshot.outcomes[1].quote_token = Some("USDC".to_string());
402        } else {
403            snapshot.outcomes.clear();
404        }
405
406        let defs = parse_outcome_instruments(&original).unwrap();
407        let instrument = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
408        client.cache_instrument(&instrument);
409
410        let settlement = OutcomeSettlement {
411            outcome_index: 2,
412            outcome_side: 0,
413            final_value: 1,
414        };
415
416        let mut tracker = OutcomeSettlementTracker::new();
417        let fills = build_settlement_fills(
418            &[settlement],
419            &spot_state_with("+20", dec!(7.5)),
420            &snapshot,
421            &client,
422            &mut tracker,
423            account(),
424            UnixNanos::default(),
425        );
426
427        assert_eq!(fills.len(), 1);
428        assert_eq!(fills[0].instrument_id, instrument.id());
429        assert_eq!(fills[0].commission, Money::zero(get_usdh_currency()));
430    }
431
432    #[rstest]
433    fn empty_settlements_emit_nothing() {
434        let mut tracker = OutcomeSettlementTracker::new();
435        let state = SpotClearinghouseState::default();
436        let fills = build_settlement_fills(
437            &[],
438            &state,
439            &meta(),
440            &client(),
441            &mut tracker,
442            account(),
443            UnixNanos::default(),
444        );
445        assert!(fills.is_empty());
446        assert!(tracker.is_empty());
447    }
448
449    #[rstest]
450    fn winning_side_emits_close_at_one_usdc() {
451        let settlement = OutcomeSettlement {
452            outcome_index: 1,
453            outcome_side: 0,
454            final_value: 1,
455        };
456        let state = spot_state_with("+10", dec!(25));
457        let mut tracker = OutcomeSettlementTracker::new();
458
459        let fills = build_settlement_fills(
460            &[settlement],
461            &state,
462            &meta(),
463            &client(),
464            &mut tracker,
465            account(),
466            UnixNanos::default(),
467        );
468
469        assert_eq!(fills.len(), 1);
470        let fill = &fills[0];
471        assert_eq!(
472            fill.instrument_id,
473            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID"),
474        );
475        assert_eq!(fill.order_side, OrderSide::Sell);
476        assert_eq!(fill.last_qty.as_decimal(), dec!(25));
477        // Quantity must match the outcome instrument's size precision (0),
478        // not the quote currency precision
479        assert_eq!(fill.last_qty.precision, 0);
480        assert_eq!(fill.last_px.as_decimal(), dec!(1));
481        assert_eq!(fill.last_px.precision, 5);
482        assert_eq!(fill.commission, Money::zero(Currency::USDC()));
483        assert!(fill.commission.as_decimal().is_zero());
484        assert!(tracker.contains(1, 0));
485    }
486
487    #[rstest]
488    fn fractional_balance_rounds_to_outcome_size_precision() {
489        // 25.1234567 should land at 25 once snapped to OUTCOME_SIZE_DECIMALS (0)
490        let settlement = OutcomeSettlement {
491            outcome_index: 4,
492            outcome_side: 0,
493            final_value: 1,
494        };
495        let state = spot_state_with("+40", dec!(25.1234567));
496        let mut tracker = OutcomeSettlementTracker::new();
497
498        let fills = build_settlement_fills(
499            &[settlement],
500            &state,
501            &meta(),
502            &client(),
503            &mut tracker,
504            account(),
505            UnixNanos::default(),
506        );
507
508        assert_eq!(fills.len(), 1);
509        assert_eq!(fills[0].last_qty.precision, 0);
510        assert_eq!(fills[0].last_qty.as_decimal(), dec!(25));
511    }
512
513    #[rstest]
514    fn losing_side_emits_close_at_zero_usdc() {
515        let settlement = OutcomeSettlement {
516            outcome_index: 1,
517            outcome_side: 1,
518            final_value: 0,
519        };
520        let state = spot_state_with("+11", dec!(10));
521        let mut tracker = OutcomeSettlementTracker::new();
522
523        let fills = build_settlement_fills(
524            &[settlement],
525            &state,
526            &meta(),
527            &client(),
528            &mut tracker,
529            account(),
530            UnixNanos::default(),
531        );
532
533        assert_eq!(fills.len(), 1);
534        let fill = &fills[0];
535        assert_eq!(
536            fill.instrument_id,
537            InstrumentId::from("1-NO-OUTCOME.HYPERLIQUID"),
538        );
539        assert_eq!(fill.last_px.as_decimal(), dec!(0));
540        assert!(tracker.contains(1, 1));
541    }
542
543    #[rstest]
544    fn unheld_settlement_marks_tracker_without_fill() {
545        let settlement = OutcomeSettlement {
546            outcome_index: 5,
547            outcome_side: 0,
548            final_value: 1,
549        };
550        let state = SpotClearinghouseState::default();
551        let mut tracker = OutcomeSettlementTracker::new();
552
553        let fills = build_settlement_fills(
554            &[settlement],
555            &state,
556            &meta(),
557            &client(),
558            &mut tracker,
559            account(),
560            UnixNanos::default(),
561        );
562
563        assert!(fills.is_empty());
564        // Tracker still records the settlement so subsequent polls skip it
565        assert!(tracker.contains(5, 0));
566    }
567
568    #[rstest]
569    fn zero_balance_skipped_and_marked() {
570        let settlement = OutcomeSettlement {
571            outcome_index: 7,
572            outcome_side: 0,
573            final_value: 1,
574        };
575        let state = spot_state_with("+70", Decimal::ZERO);
576        let mut tracker = OutcomeSettlementTracker::new();
577
578        let fills = build_settlement_fills(
579            &[settlement],
580            &state,
581            &meta(),
582            &client(),
583            &mut tracker,
584            account(),
585            UnixNanos::default(),
586        );
587
588        assert!(fills.is_empty());
589        assert!(tracker.contains(7, 0));
590    }
591
592    #[rstest]
593    fn repeated_settlement_is_idempotent() {
594        let settlement = OutcomeSettlement {
595            outcome_index: 2,
596            outcome_side: 0,
597            final_value: 1,
598        };
599        let state = spot_state_with("+20", dec!(5));
600        let mut tracker = OutcomeSettlementTracker::new();
601
602        let first = build_settlement_fills(
603            &[settlement],
604            &state,
605            &meta(),
606            &client(),
607            &mut tracker,
608            account(),
609            UnixNanos::default(),
610        );
611        let second = build_settlement_fills(
612            &[settlement],
613            &state,
614            &meta(),
615            &client(),
616            &mut tracker,
617            account(),
618            UnixNanos::default(),
619        );
620
621        assert_eq!(first.len(), 1);
622        assert!(second.is_empty(), "repeat dispatch must not re-emit fills");
623    }
624
625    #[rstest]
626    fn deterministic_identifiers_per_settlement() {
627        let settlement = OutcomeSettlement {
628            outcome_index: 3,
629            outcome_side: 1,
630            final_value: 0,
631        };
632        let state = spot_state_with("+31", dec!(1));
633        let mut tracker = OutcomeSettlementTracker::new();
634        let fills = build_settlement_fills(
635            &[settlement],
636            &state,
637            &meta(),
638            &client(),
639            &mut tracker,
640            account(),
641            UnixNanos::default(),
642        );
643        let fill = &fills[0];
644        assert_eq!(fill.venue_order_id.as_str(), "HYPERLIQUID-SETTLE-3-1");
645        assert_eq!(fill.trade_id.as_str(), "HYPERLIQUID-SETTLE-3-1-0");
646    }
647}