Skip to main content

nautilus_kraken/websocket/futures/
parse.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//! WebSocket message parsers for converting Kraken Futures streaming data to Nautilus domain models.
17
18use anyhow::Context;
19use nautilus_core::{UUID4, datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos};
20use nautilus_model::{
21    data::{
22        BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate, OrderBookDelta, QuoteTick,
23        TradeTick,
24    },
25    enums::{
26        AggressorSide, BookAction, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType,
27    },
28    identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
29    instruments::{Instrument, any::InstrumentAny},
30    reports::{FillReport, OrderStatusReport},
31    types::{Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34
35use super::messages::{
36    KrakenFuturesBookDelta, KrakenFuturesBookSnapshot, KrakenFuturesFill, KrakenFuturesOpenOrder,
37    KrakenFuturesTickerData, KrakenFuturesTradeData,
38};
39use crate::common::{enums::KrakenOrderSide, parse::fee_currency};
40
41fn millis_to_nanos(millis: i64) -> UnixNanos {
42    UnixNanos::from((millis as u64) * NANOSECONDS_IN_MILLISECOND)
43}
44
45pub fn parse_futures_ws_quote_tick(
46    ticker: &KrakenFuturesTickerData,
47    instrument: &InstrumentAny,
48    ts_init: UnixNanos,
49) -> anyhow::Result<QuoteTick> {
50    let price_precision = instrument.price_precision();
51    let size_precision = instrument.size_precision();
52
53    let bid = ticker.bid.context("Ticker missing bid")?;
54    let ask = ticker.ask.context("Ticker missing ask")?;
55    let bid_size = ticker.bid_size.unwrap_or(Decimal::ZERO);
56    let ask_size = ticker.ask_size.unwrap_or(Decimal::ZERO);
57
58    let bid_price =
59        Price::from_decimal_dp(bid, price_precision).context("Failed to construct bid Price")?;
60    let ask_price =
61        Price::from_decimal_dp(ask, price_precision).context("Failed to construct ask Price")?;
62    let bid_qty = Quantity::from_decimal_dp(bid_size, size_precision)
63        .context("Failed to construct bid Quantity")?;
64    let ask_qty = Quantity::from_decimal_dp(ask_size, size_precision)
65        .context("Failed to construct ask Quantity")?;
66
67    let ts_event = ticker.time.map_or(ts_init, millis_to_nanos);
68
69    Ok(QuoteTick::new(
70        instrument.id(),
71        bid_price,
72        ask_price,
73        bid_qty,
74        ask_qty,
75        ts_event,
76        ts_init,
77    ))
78}
79
80pub fn parse_futures_ws_trade_tick(
81    trade: &KrakenFuturesTradeData,
82    instrument: &InstrumentAny,
83    ts_init: UnixNanos,
84) -> anyhow::Result<TradeTick> {
85    let price_precision = instrument.price_precision();
86    let size_precision = instrument.size_precision();
87
88    let price = Price::from_decimal_dp(trade.price, price_precision)
89        .context("Failed to construct trade Price")?;
90    let size = Quantity::from_decimal_dp(trade.qty, size_precision)
91        .context("Failed to construct trade Quantity")?;
92
93    let aggressor = match trade.side {
94        KrakenOrderSide::Buy => AggressorSide::Buy,
95        KrakenOrderSide::Sell => AggressorSide::Sell,
96    };
97
98    let trade_id = trade
99        .uid
100        .as_deref()
101        .map_or_else(|| TradeId::new(trade.seq.to_string()), TradeId::new);
102
103    let ts_event = millis_to_nanos(trade.time);
104
105    TradeTick::new_checked(
106        instrument.id(),
107        price,
108        size,
109        aggressor,
110        trade_id,
111        ts_event,
112        ts_init,
113    )
114    .context("Failed to construct TradeTick from Kraken futures trade")
115}
116
117pub fn parse_futures_ws_book_snapshot_deltas(
118    snapshot: &KrakenFuturesBookSnapshot,
119    instrument: &InstrumentAny,
120    sequence: u64,
121    ts_init: UnixNanos,
122) -> anyhow::Result<Vec<OrderBookDelta>> {
123    let instrument_id = instrument.id();
124    let price_precision = instrument.price_precision();
125    let size_precision = instrument.size_precision();
126    let ts_event = millis_to_nanos(snapshot.timestamp);
127
128    let capacity = snapshot.bids.len() + snapshot.asks.len() + 1;
129    let mut deltas = Vec::with_capacity(capacity);
130    let mut seq = sequence;
131
132    // Leading CLEAR delta to reset the book
133    deltas.push(OrderBookDelta::clear(instrument_id, seq, ts_event, ts_init));
134    seq += 1;
135
136    for level in &snapshot.bids {
137        if level.qty <= Decimal::ZERO {
138            continue;
139        }
140        let price = Price::from_decimal_dp(level.price, price_precision)?;
141        let size = Quantity::from_decimal_dp(level.qty, size_precision)?;
142        let order_id = price.raw() as u64;
143
144        let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
145        deltas.push(OrderBookDelta::new(
146            instrument_id,
147            BookAction::Add,
148            order,
149            0,
150            seq,
151            ts_event,
152            ts_init,
153        ));
154        seq += 1;
155    }
156
157    for level in &snapshot.asks {
158        if level.qty <= Decimal::ZERO {
159            continue;
160        }
161        let price = Price::from_decimal_dp(level.price, price_precision)?;
162        let size = Quantity::from_decimal_dp(level.qty, size_precision)?;
163        let order_id = price.raw() as u64;
164
165        let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
166        deltas.push(OrderBookDelta::new(
167            instrument_id,
168            BookAction::Add,
169            order,
170            0,
171            seq,
172            ts_event,
173            ts_init,
174        ));
175        seq += 1;
176    }
177
178    Ok(deltas)
179}
180
181pub fn parse_futures_ws_book_delta(
182    delta: &KrakenFuturesBookDelta,
183    instrument: &InstrumentAny,
184    sequence: u64,
185    ts_init: UnixNanos,
186) -> anyhow::Result<OrderBookDelta> {
187    let price_precision = instrument.price_precision();
188    let size_precision = instrument.size_precision();
189
190    let price = Price::from_decimal_dp(delta.price, price_precision)?;
191    let size = Quantity::from_decimal_dp(delta.qty, size_precision)?;
192
193    let action = if size.is_zero() {
194        BookAction::Delete
195    } else {
196        BookAction::Update
197    };
198
199    let side = match delta.side {
200        KrakenOrderSide::Buy => OrderSide::Buy,
201        KrakenOrderSide::Sell => OrderSide::Sell,
202    };
203
204    let order_id = price.raw() as u64;
205
206    let order = BookOrder::new(side, price, size, order_id);
207    let ts_event = millis_to_nanos(delta.timestamp);
208
209    Ok(OrderBookDelta::new(
210        instrument.id(),
211        action,
212        order,
213        0,
214        sequence,
215        ts_event,
216        ts_init,
217    ))
218}
219
220fn parse_ws_direction(direction: i32) -> OrderSide {
221    if direction == 0 {
222        OrderSide::Buy
223    } else {
224        OrderSide::Sell
225    }
226}
227
228fn infer_order_status(order: &KrakenFuturesOpenOrder, is_cancel: bool) -> OrderStatus {
229    if order.filled >= order.qty && order.qty > Decimal::ZERO {
230        OrderStatus::Filled
231    } else if is_cancel {
232        OrderStatus::Canceled
233    } else if order.filled > Decimal::ZERO {
234        OrderStatus::PartiallyFilled
235    } else {
236        OrderStatus::Accepted
237    }
238}
239
240pub fn parse_futures_ws_order_status_report(
241    order: &KrakenFuturesOpenOrder,
242    is_cancel: bool,
243    reason: Option<&str>,
244    instrument: &InstrumentAny,
245    account_id: AccountId,
246    ts_init: UnixNanos,
247) -> anyhow::Result<OrderStatusReport> {
248    let venue_order_id = VenueOrderId::new(&order.order_id);
249    let order_side = parse_ws_direction(order.direction);
250    let order_type = OrderType::from(order.order_type);
251    let order_type = if order_type == OrderType::MarketIfTouched && order.limit_price.is_some() {
252        OrderType::LimitIfTouched
253    } else {
254        order_type
255    };
256    let order_status = infer_order_status(order, is_cancel);
257
258    let price_precision = instrument.price_precision();
259    let size_precision = instrument.size_precision();
260
261    let quantity = Quantity::from_decimal_dp(order.qty, size_precision)
262        .context("Failed to parse order qty")?;
263    let filled_qty = Quantity::from_decimal_dp(order.filled, size_precision)
264        .context("Failed to parse order filled")?;
265
266    let ts_accepted = millis_to_nanos(order.time);
267    let ts_last = millis_to_nanos(order.last_update_time);
268
269    let mut report = OrderStatusReport {
270        account_id,
271        instrument_id: instrument.id(),
272        client_order_id: order.cli_ord_id.as_ref().map(ClientOrderId::new),
273        venue_order_id,
274        order_side: order_side.into(),
275        order_type,
276        time_in_force: TimeInForce::Gtc,
277        order_status,
278        quantity,
279        filled_qty,
280        report_id: UUID4::new(),
281        ts_accepted,
282        ts_last,
283        ts_init,
284        order_list_id: None,
285        venue_position_id: None,
286        linked_order_ids: None,
287        parent_order_id: None,
288        contingency_type: None,
289        expire_time: None,
290        price: None,
291        activation_price: None,
292        trigger_price: None,
293        trigger_type: None,
294        limit_offset: None,
295        trailing_offset: None,
296        trailing_offset_type: None,
297        display_qty: None,
298        avg_px: None,
299        post_only: false,
300        reduce_only: order.reduce_only,
301        cancel_reason: None,
302        ts_triggered: None,
303    };
304
305    if let Some(px) = order.limit_price {
306        report.price = Some(Price::from_decimal_dp(px, price_precision)?);
307    }
308
309    if let Some(px) = order.stop_price {
310        report.trigger_price = Some(Price::from_decimal_dp(px, price_precision)?);
311        report.trigger_type = Some(order.trigger_signal.as_deref().map_or(
312            TriggerType::Default,
313            |s| match s {
314                "mark" | "mark_price" => TriggerType::MarkPrice,
315                "spot" | "spot_price" | "index" | "index_price" => TriggerType::IndexPrice,
316                _ => TriggerType::LastPrice,
317            },
318        ));
319    }
320
321    if let Some(reason) = reason
322        && !reason.is_empty()
323    {
324        report.cancel_reason = Some(reason.to_string());
325    }
326
327    Ok(report)
328}
329
330pub fn parse_futures_ws_fill_report(
331    fill: &KrakenFuturesFill,
332    instrument: &InstrumentAny,
333    account_id: AccountId,
334    ts_init: UnixNanos,
335) -> anyhow::Result<FillReport> {
336    let price_precision = instrument.price_precision();
337    let size_precision = instrument.size_precision();
338
339    let venue_order_id = VenueOrderId::new(&fill.order_id);
340    let trade_id = TradeId::new(&fill.fill_id);
341    let order_side = if fill.buy {
342        OrderSide::Buy
343    } else {
344        OrderSide::Sell
345    };
346
347    let last_qty =
348        Quantity::from_decimal_dp(fill.qty, size_precision).context("Failed to parse fill qty")?;
349    let last_px = Price::from_decimal_dp(fill.price, price_precision)
350        .context("Failed to parse fill price")?;
351
352    let liquidity_side = fill.fill_type.into();
353
354    let fee = fill.fee_paid.unwrap_or(Decimal::ZERO);
355    // The venue reports the fee currency, which on an inverse contract is not the quote.
356    let commission_currency = fill.fee_currency.as_deref().map_or_else(
357        || instrument.quote_currency(),
358        |reported| fee_currency(reported, instrument),
359    );
360    let commission = Money::from_decimal(fee, commission_currency)?;
361
362    let ts_event = millis_to_nanos(fill.time);
363
364    let client_order_id = fill
365        .cli_ord_id
366        .as_ref()
367        .filter(|s| !s.is_empty())
368        .map(ClientOrderId::new);
369
370    Ok(FillReport::new(
371        account_id,
372        instrument.id(),
373        venue_order_id,
374        trade_id,
375        order_side,
376        last_qty,
377        last_px,
378        commission,
379        liquidity_side,
380        client_order_id,
381        None, // venue_position_id
382        ts_event,
383        ts_init,
384        None, // report_id
385    ))
386}
387
388pub fn parse_futures_ws_mark_price(
389    ticker: &KrakenFuturesTickerData,
390    instrument: &InstrumentAny,
391    ts_init: UnixNanos,
392) -> Option<MarkPriceUpdate> {
393    let mark_price = ticker.mark_price?;
394    let price = Price::from_decimal_dp(mark_price, instrument.price_precision()).ok()?;
395    let ts_event = ticker.time.map_or(ts_init, millis_to_nanos);
396    Some(MarkPriceUpdate::new(
397        instrument.id(),
398        price,
399        ts_event,
400        ts_init,
401    ))
402}
403
404pub fn parse_futures_ws_index_price(
405    ticker: &KrakenFuturesTickerData,
406    instrument: &InstrumentAny,
407    ts_init: UnixNanos,
408) -> Option<IndexPriceUpdate> {
409    let index = ticker.index?;
410    let price = Price::from_decimal_dp(index, instrument.price_precision()).ok()?;
411    let ts_event = ticker.time.map_or(ts_init, millis_to_nanos);
412    Some(IndexPriceUpdate::new(
413        instrument.id(),
414        price,
415        ts_event,
416        ts_init,
417    ))
418}
419
420pub fn parse_futures_ws_funding_rate(
421    ticker: &KrakenFuturesTickerData,
422    instrument: &InstrumentAny,
423    ts_init: UnixNanos,
424) -> Option<FundingRateUpdate> {
425    let rate = ticker.relative_funding_rate?;
426    let ts_event = ticker.time.map_or(ts_init, millis_to_nanos);
427    let next_funding_ns = ticker
428        .next_funding_rate_time
429        .map(|t| millis_to_nanos(t as i64));
430    Some(FundingRateUpdate::new(
431        instrument.id(),
432        rate,
433        None,
434        next_funding_ns,
435        ts_event,
436        ts_init,
437    ))
438}
439
440#[cfg(test)]
441mod tests {
442    use nautilus_model::{
443        enums::{CurrencyType, LiquiditySide, OrderSide},
444        identifiers::{InstrumentId, Symbol},
445        instruments::crypto_perpetual::CryptoPerpetual,
446        types::Currency,
447    };
448    use rstest::rstest;
449    use rust_decimal_macros::dec;
450
451    use super::*;
452    use crate::common::{
453        consts::KRAKEN_VENUE,
454        enums::{KrakenFillType, KrakenFuturesOrderType},
455    };
456
457    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
458
459    fn create_mock_perp() -> InstrumentAny {
460        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
461        InstrumentAny::CryptoPerpetual(
462            CryptoPerpetual::builder()
463                .instrument_id(instrument_id)
464                .raw_symbol(Symbol::new("PI_XBTUSD"))
465                .base_currency(Currency::BTC())
466                .quote_currency(Currency::USD())
467                .settlement_currency(Currency::USD())
468                .is_inverse(false)
469                .price_precision(1)
470                .size_precision(0)
471                .price_increment(Price::from("0.5"))
472                .size_increment(Quantity::from("1"))
473                .ts_event(TS)
474                .ts_init(TS)
475                .build()
476                .unwrap(),
477        )
478    }
479
480    #[rstest]
481    fn test_parse_futures_ws_quote_tick() {
482        let json = include_str!("../../../test_data/ws_futures_ticker.json");
483        let ticker: KrakenFuturesTickerData = serde_json::from_str(json).unwrap();
484        let instrument = create_mock_perp();
485
486        let quote = parse_futures_ws_quote_tick(&ticker, &instrument, TS).unwrap();
487
488        assert_eq!(quote.instrument_id, instrument.id());
489        assert_eq!(quote.bid_price, Price::from("21978.5"));
490        assert_eq!(quote.ask_price, Price::from("21987.0"));
491        assert_eq!(quote.bid_size, Quantity::from("2536.0"));
492        assert_eq!(quote.ask_size, Quantity::from("13948.0"));
493    }
494
495    #[rstest]
496    fn test_parse_futures_ws_trade_tick() {
497        let json = include_str!("../../../test_data/ws_futures_trade.json");
498        let trade: KrakenFuturesTradeData = serde_json::from_str(json).unwrap();
499        let instrument = create_mock_perp();
500
501        let tick = parse_futures_ws_trade_tick(&trade, &instrument, TS).unwrap();
502
503        assert_eq!(tick.instrument_id, instrument.id());
504        assert_eq!(tick.price, Price::from("34969.5"));
505        assert_eq!(tick.size, Quantity::from("15000.0"));
506        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
507    }
508
509    #[rstest]
510    fn test_parse_futures_ws_book_snapshot() {
511        let json = include_str!("../../../test_data/ws_futures_book_snapshot.json");
512        let snapshot: KrakenFuturesBookSnapshot = serde_json::from_str(json).unwrap();
513        let instrument = create_mock_perp();
514
515        let deltas = parse_futures_ws_book_snapshot_deltas(&snapshot, &instrument, 0, TS).unwrap();
516
517        // CLEAR + 2 bids + 2 asks = 5
518        assert_eq!(deltas.len(), 5);
519        assert_eq!(deltas[0].action, BookAction::Clear);
520        assert_eq!(deltas[1].action, BookAction::Add);
521        assert_eq!(deltas[1].order.side, OrderSide::Buy.into());
522        assert_eq!(deltas[3].order.side, OrderSide::Sell.into());
523    }
524
525    #[rstest]
526    fn test_parse_futures_ws_book_snapshot_skips_zero_qty() {
527        let json = include_str!("../../../test_data/ws_futures_book_snapshot_with_zero_qty.json");
528        let snapshot: KrakenFuturesBookSnapshot = serde_json::from_str(json).unwrap();
529        let instrument = create_mock_perp();
530
531        let deltas = parse_futures_ws_book_snapshot_deltas(&snapshot, &instrument, 0, TS).unwrap();
532
533        // CLEAR + 2 bids (skipped qty=0) + 1 ask (skipped qty=0) = 4
534        assert_eq!(deltas.len(), 4);
535        assert_eq!(deltas[0].action, BookAction::Clear);
536        assert_eq!(deltas[1].order.side, OrderSide::Buy.into());
537        assert_eq!(deltas[1].order.price, Price::from("34892.5"));
538        assert_eq!(deltas[2].order.side, OrderSide::Buy.into());
539        assert_eq!(deltas[2].order.price, Price::from("34891.5"));
540        assert_eq!(deltas[3].order.side, OrderSide::Sell.into());
541        assert_eq!(deltas[3].order.price, Price::from("34912.0"));
542    }
543
544    #[rstest]
545    fn test_parse_futures_ws_book_delta() {
546        let json = include_str!("../../../test_data/ws_futures_book_delta.json");
547        let delta_msg: KrakenFuturesBookDelta = serde_json::from_str(json).unwrap();
548        let instrument = create_mock_perp();
549
550        let delta = parse_futures_ws_book_delta(&delta_msg, &instrument, 10, TS).unwrap();
551
552        assert_eq!(delta.instrument_id, instrument.id());
553        assert_eq!(delta.order.side, OrderSide::Sell.into());
554        assert_eq!(delta.action, BookAction::Delete); // qty=0
555        assert_eq!(delta.sequence, 10);
556    }
557
558    #[rstest]
559    fn test_parse_futures_ws_order_status_report_new_order() {
560        let order = KrakenFuturesOpenOrder {
561            instrument: ustr::Ustr::from("PI_XBTUSD"),
562            time: 1700000000000,
563            last_update_time: 1700000000100,
564            qty: dec!(1000),
565            filled: Decimal::ZERO,
566            limit_price: Some(dec!(35000)),
567            stop_price: None,
568            order_type: KrakenFuturesOrderType::Limit,
569            order_id: "abc-123".to_string(),
570            cli_ord_id: Some("my-order-1".to_string()),
571            direction: 0,
572            reduce_only: false,
573            trigger_signal: None,
574        };
575        let instrument = create_mock_perp();
576        let account_id = AccountId::from("KRAKEN-001");
577
578        let report =
579            parse_futures_ws_order_status_report(&order, false, None, &instrument, account_id, TS)
580                .unwrap();
581
582        assert_eq!(report.order_status, OrderStatus::Accepted);
583        assert_eq!(report.order_side, OrderSide::Buy.into());
584        assert_eq!(report.order_type, OrderType::Limit);
585        assert_eq!(report.quantity.as_decimal(), dec!(1000));
586        assert_eq!(report.filled_qty.as_decimal(), Decimal::ZERO);
587        assert_eq!(report.price.unwrap().as_decimal(), dec!(35000));
588    }
589
590    #[rstest]
591    fn test_parse_futures_ws_order_status_report_canceled() {
592        let order = KrakenFuturesOpenOrder {
593            instrument: ustr::Ustr::from("PI_XBTUSD"),
594            time: 1700000000000,
595            last_update_time: 1700000001000,
596            qty: dec!(1000),
597            filled: Decimal::ZERO,
598            limit_price: Some(dec!(35000)),
599            stop_price: None,
600            order_type: KrakenFuturesOrderType::Limit,
601            order_id: "abc-123".to_string(),
602            cli_ord_id: None,
603            direction: 1,
604            reduce_only: false,
605            trigger_signal: None,
606        };
607        let instrument = create_mock_perp();
608        let account_id = AccountId::from("KRAKEN-001");
609
610        let report = parse_futures_ws_order_status_report(
611            &order,
612            true,
613            Some("cancelled_by_user"),
614            &instrument,
615            account_id,
616            TS,
617        )
618        .unwrap();
619
620        assert_eq!(report.order_status, OrderStatus::Canceled);
621        assert_eq!(report.order_side, OrderSide::Sell.into());
622        assert_eq!(report.cancel_reason.as_deref(), Some("cancelled_by_user"));
623    }
624
625    #[rstest]
626    fn test_parse_futures_ws_order_status_report_market_if_touched() {
627        let order = KrakenFuturesOpenOrder {
628            instrument: ustr::Ustr::from("PI_XBTUSD"),
629            time: 1700000000000,
630            last_update_time: 1700000000100,
631            qty: dec!(500),
632            filled: Decimal::ZERO,
633            limit_price: None,
634            stop_price: Some(dec!(36000)),
635            order_type: KrakenFuturesOrderType::TakeProfit,
636            order_id: "tp-001".to_string(),
637            cli_ord_id: Some("my-tp-1".to_string()),
638            direction: 0,
639            reduce_only: true,
640            trigger_signal: None,
641        };
642        let instrument = create_mock_perp();
643        let account_id = AccountId::from("KRAKEN-001");
644
645        let report =
646            parse_futures_ws_order_status_report(&order, false, None, &instrument, account_id, TS)
647                .unwrap();
648
649        assert_eq!(report.order_type, OrderType::MarketIfTouched);
650        assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
651        assert!(report.price.is_none());
652        assert!(report.reduce_only);
653    }
654
655    #[rstest]
656    fn test_parse_futures_ws_order_status_report_limit_if_touched() {
657        let order = KrakenFuturesOpenOrder {
658            instrument: ustr::Ustr::from("PI_XBTUSD"),
659            time: 1700000000000,
660            last_update_time: 1700000000100,
661            qty: dec!(500),
662            filled: Decimal::ZERO,
663            limit_price: Some(dec!(35500)),
664            stop_price: Some(dec!(36000)),
665            order_type: KrakenFuturesOrderType::TakeProfit,
666            order_id: "tpl-001".to_string(),
667            cli_ord_id: Some("my-tpl-1".to_string()),
668            direction: 1,
669            reduce_only: false,
670            trigger_signal: None,
671        };
672        let instrument = create_mock_perp();
673        let account_id = AccountId::from("KRAKEN-001");
674
675        let report =
676            parse_futures_ws_order_status_report(&order, false, None, &instrument, account_id, TS)
677                .unwrap();
678
679        assert_eq!(report.order_type, OrderType::LimitIfTouched);
680        assert_eq!(report.trigger_price.unwrap().as_decimal(), dec!(36000));
681        assert_eq!(report.price.unwrap().as_decimal(), dec!(35500));
682        assert_eq!(report.order_side, OrderSide::Sell.into());
683    }
684
685    #[rstest]
686    fn test_parse_futures_ws_order_status_report_spot_trigger_signal() {
687        let order = KrakenFuturesOpenOrder {
688            instrument: ustr::Ustr::from("PI_XBTUSD"),
689            time: 1700000000000,
690            last_update_time: 1700000000100,
691            qty: dec!(500),
692            filled: Decimal::ZERO,
693            limit_price: None,
694            stop_price: Some(dec!(36000)),
695            order_type: KrakenFuturesOrderType::TakeProfit,
696            order_id: "tp-spot-001".to_string(),
697            cli_ord_id: Some("my-tp-spot-1".to_string()),
698            direction: 0,
699            reduce_only: false,
700            trigger_signal: Some("spot".to_string()),
701        };
702        let instrument = create_mock_perp();
703        let account_id = AccountId::from("KRAKEN-001");
704
705        let report =
706            parse_futures_ws_order_status_report(&order, false, None, &instrument, account_id, TS)
707                .unwrap();
708
709        assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
710    }
711
712    #[rstest]
713    #[case::taker(KrakenFillType::Taker, LiquiditySide::Taker)]
714    #[case::assignee(KrakenFillType::Assignee, LiquiditySide::NoLiquiditySide)]
715    fn test_parse_futures_ws_fill_report(
716        #[case] fill_type: KrakenFillType,
717        #[case] expected_liquidity_side: LiquiditySide,
718    ) {
719        let json = include_str!("../../../test_data/ws_futures_fills_delta.json");
720        let fills_delta: super::super::messages::KrakenFuturesFillsDelta =
721            serde_json::from_str(json).unwrap();
722        let mut fill = fills_delta.fills[0].clone();
723        fill.fill_type = fill_type;
724
725        let instrument_id = InstrumentId::new(Symbol::new("PF_ETHUSD"), *KRAKEN_VENUE);
726        let usd = Currency::new("USD", 6, 0, "USD", CurrencyType::Fiat);
727        let instrument = InstrumentAny::CryptoPerpetual(
728            CryptoPerpetual::builder()
729                .instrument_id(instrument_id)
730                .raw_symbol(Symbol::new("PF_ETHUSD"))
731                .base_currency(Currency::ETH())
732                .quote_currency(usd)
733                .settlement_currency(usd)
734                .is_inverse(false)
735                .price_precision(1)
736                .size_precision(3)
737                .price_increment(Price::from("0.5"))
738                .size_increment(Quantity::from("0.001"))
739                .ts_event(TS)
740                .ts_init(TS)
741                .build()
742                .unwrap(),
743        );
744
745        let account_id = AccountId::from("KRAKEN-001");
746        let report = parse_futures_ws_fill_report(&fill, &instrument, account_id, TS).unwrap();
747
748        assert_eq!(report.instrument_id, instrument_id);
749        assert_eq!(report.order_side, OrderSide::Buy);
750        assert_eq!(report.last_px.as_decimal(), dec!(3162));
751        assert_eq!(report.last_qty.as_decimal(), dec!(0.001));
752        assert_eq!(report.liquidity_side, expected_liquidity_side);
753        assert_eq!(report.commission.as_decimal(), dec!(0.001581));
754        // The fixture reports the quote currency, so the commission stays in it.
755        assert_eq!(report.commission.currency.code, "USD");
756    }
757
758    /// A fee the venue reports in something other than the quote must be booked in that currency.
759    ///
760    /// An inverse contract charges in the base currency, so assuming the quote would record the
761    /// wrong currency against the amount.
762    #[rstest]
763    fn test_parse_futures_ws_fill_report_honors_the_reported_fee_currency() {
764        let json = include_str!("../../../test_data/ws_futures_fills_delta.json");
765        let fills_delta: super::super::messages::KrakenFuturesFillsDelta =
766            serde_json::from_str(json).unwrap();
767        let mut fill = fills_delta.fills[0].clone();
768        fill.fee_currency = Some("XXBT".to_string());
769
770        let instrument_id = InstrumentId::new(Symbol::new("PI_XBTUSD"), *KRAKEN_VENUE);
771        let usd = Currency::new("USD", 6, 0, "USD", CurrencyType::Fiat);
772        let instrument = InstrumentAny::CryptoPerpetual(
773            CryptoPerpetual::builder()
774                .instrument_id(instrument_id)
775                .raw_symbol(Symbol::new("PI_XBTUSD"))
776                .base_currency(Currency::BTC())
777                .quote_currency(usd)
778                .settlement_currency(Currency::BTC())
779                .is_inverse(true)
780                .price_precision(1)
781                .size_precision(3)
782                .price_increment(Price::from("0.5"))
783                .size_increment(Quantity::from("0.001"))
784                .ts_event(TS)
785                .ts_init(TS)
786                .build()
787                .unwrap(),
788        );
789
790        let report =
791            parse_futures_ws_fill_report(&fill, &instrument, AccountId::from("KRAKEN-001"), TS)
792                .unwrap();
793
794        // `XXBT` is Kraken's spelling; the report carries the standard code.
795        assert_eq!(report.commission.currency.code, "BTC");
796        assert_eq!(report.commission.as_decimal(), dec!(0.001581));
797    }
798}