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 models::SpotClearinghouseState,
42 parse::{
43 OUTCOME_PRICE_DECIMALS, OUTCOME_SIZE_DECIMALS, OutcomeSettlement, get_usdh_currency,
44 },
45 },
46};
47
48#[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#[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 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 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 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 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 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}