1use 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#[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#[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 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 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 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 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 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}