use ahash::AHashSet;
use nautilus_core::{UUID4, UnixNanos};
use nautilus_model::{
enums::{LiquiditySide, OrderSide},
identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
reports::FillReport,
types::{Currency, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use crate::{
common::{converters::outcome_asset_id_to_instrument_id, types::HyperliquidAssetId},
http::{
models::SpotClearinghouseState,
parse::{
OUTCOME_PRICE_DECIMALS, OUTCOME_SIZE_DECIMALS, OutcomeSettlement, get_usdh_currency,
},
},
};
#[derive(Debug, Default)]
pub struct OutcomeSettlementTracker {
processed: AHashSet<(u32, u8)>,
}
impl OutcomeSettlementTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.processed.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.processed.is_empty()
}
#[must_use]
pub fn contains(&self, outcome_index: u32, outcome_side: u8) -> bool {
self.processed.contains(&(outcome_index, outcome_side))
}
fn mark(&mut self, outcome_index: u32, outcome_side: u8) -> bool {
self.processed.insert((outcome_index, outcome_side))
}
}
#[must_use]
pub fn build_settlement_fills(
settlements: &[OutcomeSettlement],
spot_state: &SpotClearinghouseState,
tracker: &mut OutcomeSettlementTracker,
account_id: AccountId,
ts: UnixNanos,
) -> Vec<FillReport> {
if settlements.is_empty() {
return Vec::new();
}
let usdh = get_usdh_currency();
let mut fills = Vec::new();
for settlement in settlements {
if tracker.contains(settlement.outcome_index, settlement.outcome_side) {
continue;
}
let asset_id =
HyperliquidAssetId::outcome(settlement.outcome_index, settlement.outcome_side);
let Some(encoding) = asset_id.outcome_encoding() else {
continue;
};
let token_coin = format!("+{encoding}");
let Some(balance) = spot_state
.balances
.iter()
.find(|b| b.coin.as_str() == token_coin && !b.total.is_zero())
else {
tracker.mark(settlement.outcome_index, settlement.outcome_side);
continue;
};
let instrument_id = match outcome_asset_id_to_instrument_id(asset_id) {
Ok(id) => id,
Err(e) => {
log::error!("Outcome settlement skipped, instrument id resolution failed: {e}",);
continue;
}
};
if let Some(fill) = build_close_fill(
instrument_id,
account_id,
settlement,
balance.total,
usdh,
ts,
) {
fills.push(fill);
tracker.mark(settlement.outcome_index, settlement.outcome_side);
}
}
fills
}
fn build_close_fill(
instrument_id: InstrumentId,
account_id: AccountId,
settlement: &OutcomeSettlement,
quantity: Decimal,
currency: Currency,
ts: UnixNanos,
) -> Option<FillReport> {
let qty = Quantity::from_decimal_dp(quantity, OUTCOME_SIZE_DECIMALS as u8).ok()?;
let price = Price::from_decimal_dp(
Decimal::from(settlement.final_value),
OUTCOME_PRICE_DECIMALS as u8,
)
.ok()?;
let tag = format!(
"SETTLE-{}-{}",
settlement.outcome_index, settlement.outcome_side,
);
let venue_order_id = VenueOrderId::new(format!("HYPERLIQUID-{tag}"));
let trade_id = TradeId::new(format!("HYPERLIQUID-{tag}-{}", settlement.final_value));
Some(FillReport::new(
account_id,
instrument_id,
venue_order_id,
trade_id,
OrderSide::Sell,
qty,
price,
Money::zero(currency),
LiquiditySide::NoLiquiditySide,
None,
None,
ts,
ts,
Some(UUID4::new()),
))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use ustr::Ustr;
use super::*;
use crate::http::models::SpotBalance;
fn account() -> AccountId {
AccountId::new("HYPERLIQUID-001")
}
fn spot_state_with(coin: &str, total: Decimal) -> SpotClearinghouseState {
SpotClearinghouseState {
balances: vec![SpotBalance {
coin: Ustr::from(coin),
token: None,
total,
hold: Decimal::ZERO,
entry_ntl: None,
}],
}
}
#[rstest]
fn empty_settlements_emit_nothing() {
let mut tracker = OutcomeSettlementTracker::new();
let state = SpotClearinghouseState::default();
let fills =
build_settlement_fills(&[], &state, &mut tracker, account(), UnixNanos::default());
assert!(fills.is_empty());
assert!(tracker.is_empty());
}
#[rstest]
fn winning_side_emits_close_at_one_usdh() {
let settlement = OutcomeSettlement {
outcome_index: 1,
outcome_side: 0,
final_value: 1,
};
let state = spot_state_with("+10", dec!(25));
let mut tracker = OutcomeSettlementTracker::new();
let fills = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
assert_eq!(fills.len(), 1);
let fill = &fills[0];
assert_eq!(fill.instrument_id, InstrumentId::from("+10.HYPERLIQUID"));
assert_eq!(fill.order_side, OrderSide::Sell);
assert_eq!(fill.last_qty.as_decimal(), dec!(25));
assert_eq!(fill.last_qty.precision, 2);
assert_eq!(fill.last_px.as_decimal(), dec!(1));
assert_eq!(fill.last_px.precision, 4);
assert_eq!(fill.commission.currency.code.as_str(), "USDH");
assert!(fill.commission.as_decimal().is_zero());
assert!(tracker.contains(1, 0));
}
#[rstest]
fn fractional_balance_rounds_to_outcome_size_precision() {
let settlement = OutcomeSettlement {
outcome_index: 4,
outcome_side: 0,
final_value: 1,
};
let state = spot_state_with("+40", dec!(25.1234567));
let mut tracker = OutcomeSettlementTracker::new();
let fills = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].last_qty.precision, 2);
assert_eq!(fills[0].last_qty.as_decimal(), dec!(25.12));
}
#[rstest]
fn losing_side_emits_close_at_zero_usdh() {
let settlement = OutcomeSettlement {
outcome_index: 1,
outcome_side: 1,
final_value: 0,
};
let state = spot_state_with("+11", dec!(10));
let mut tracker = OutcomeSettlementTracker::new();
let fills = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
assert_eq!(fills.len(), 1);
let fill = &fills[0];
assert_eq!(fill.instrument_id, InstrumentId::from("+11.HYPERLIQUID"));
assert_eq!(fill.last_px.as_decimal(), dec!(0));
assert!(tracker.contains(1, 1));
}
#[rstest]
fn unheld_settlement_marks_tracker_without_fill() {
let settlement = OutcomeSettlement {
outcome_index: 5,
outcome_side: 0,
final_value: 1,
};
let state = SpotClearinghouseState::default();
let mut tracker = OutcomeSettlementTracker::new();
let fills = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
assert!(fills.is_empty());
assert!(tracker.contains(5, 0));
}
#[rstest]
fn zero_balance_skipped_and_marked() {
let settlement = OutcomeSettlement {
outcome_index: 7,
outcome_side: 0,
final_value: 1,
};
let state = spot_state_with("+70", Decimal::ZERO);
let mut tracker = OutcomeSettlementTracker::new();
let fills = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
assert!(fills.is_empty());
assert!(tracker.contains(7, 0));
}
#[rstest]
fn repeated_settlement_is_idempotent() {
let settlement = OutcomeSettlement {
outcome_index: 2,
outcome_side: 0,
final_value: 1,
};
let state = spot_state_with("+20", dec!(5));
let mut tracker = OutcomeSettlementTracker::new();
let first = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
let second = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
assert_eq!(first.len(), 1);
assert!(second.is_empty(), "repeat dispatch must not re-emit fills");
}
#[rstest]
fn deterministic_identifiers_per_settlement() {
let settlement = OutcomeSettlement {
outcome_index: 3,
outcome_side: 1,
final_value: 0,
};
let state = spot_state_with("+31", dec!(1));
let mut tracker = OutcomeSettlementTracker::new();
let fills = build_settlement_fills(
&[settlement],
&state,
&mut tracker,
account(),
UnixNanos::default(),
);
let fill = &fills[0];
assert_eq!(fill.venue_order_id.as_str(), "HYPERLIQUID-SETTLE-3-1");
assert_eq!(fill.trade_id.as_str(), "HYPERLIQUID-SETTLE-3-1-0");
}
}