Skip to main content

nautilus_kraken/common/
order_params.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//! Pure builder functions that convert Nautilus execution commands into Kraken WS param structs.
17
18use jiff::tz::Offset;
19use nautilus_common::messages::execution::{CancelOrder, ModifyOrder, SubmitOrder};
20use nautilus_core::nanos::UnixNanos;
21use nautilus_model::{
22    enums::{OrderSide, OrderType, TimeInForce, TriggerType},
23    orders::{Order, any::OrderAny},
24};
25
26use crate::{
27    common::{
28        enums::{KrakenOrderSide, KrakenOrderType, KrakenSpotTrigger, KrakenTimeInForce},
29        parse::truncate_cl_ord_id,
30    },
31    websocket::spot_v2::messages::{
32        KrakenWsAddOrderParams, KrakenWsAmendOrderParams, KrakenWsCancelOrderParams,
33        KrakenWsTriggerParams,
34    },
35};
36
37/// Builds WebSocket `add_order` parameters from a Nautilus submit command and the cached order.
38///
39/// # Errors
40///
41/// Returns an error if:
42/// - The order side cannot be mapped to a Kraken side.
43/// - The order type is not supported on the WS path.
44/// - The time in force is not supported for the given order type.
45/// - GTD time in force is missing `expire_time`.
46pub fn build_add_order_params(
47    cmd: &SubmitOrder,
48    order: &OrderAny,
49    token: String,
50    leverage: Option<u16>,
51) -> anyhow::Result<KrakenWsAddOrderParams> {
52    let order_type = order.order_type();
53    let order_side = order.order_side();
54    let time_in_force = order.time_in_force();
55
56    let side = match order_side {
57        OrderSide::Buy => KrakenOrderSide::Buy,
58        OrderSide::Sell => KrakenOrderSide::Sell,
59    };
60
61    if matches!(
62        order_type,
63        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
64    ) {
65        anyhow::bail!("Trailing stop orders are not yet supported on the Kraken WS path; use REST",);
66    }
67
68    if order.display_qty().is_some() {
69        anyhow::bail!(
70            "Iceberg (display_qty) orders are not supported on the Kraken WS path; use REST"
71        );
72    }
73
74    let kraken_order_type = match order_type {
75        OrderType::Market => KrakenOrderType::Market,
76        OrderType::Limit => KrakenOrderType::Limit,
77        OrderType::StopMarket => KrakenOrderType::StopLoss,
78        OrderType::StopLimit => KrakenOrderType::StopLossLimit,
79        OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
80        OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
81        _ => anyhow::bail!("Unsupported order type for Kraken WS: {order_type:?}"),
82    };
83
84    let is_limit_order = matches!(
85        order_type,
86        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
87    );
88
89    if is_limit_order && order.price().is_none() {
90        anyhow::bail!("limit_price is required for order type {order_type:?}");
91    }
92
93    let ws_tif = compute_ws_time_in_force(is_limit_order, time_in_force, order.expire_time())?;
94    let expire_time = match (ws_tif, order.expire_time()) {
95        (Some(KrakenTimeInForce::GoodTilDate), Some(ts)) => Some(format_expire_time(ts)),
96        _ => None,
97    };
98
99    let is_conditional = matches!(
100        order_type,
101        OrderType::StopMarket
102            | OrderType::StopLimit
103            | OrderType::MarketIfTouched
104            | OrderType::LimitIfTouched
105    );
106
107    let limit_price = order.price().map(|p| p.as_decimal());
108
109    let trigger = if is_conditional {
110        let trigger_ref = match order.trigger_type() {
111            Some(TriggerType::IndexPrice) => KrakenSpotTrigger::Index,
112            Some(TriggerType::LastPrice | TriggerType::Default) | None => KrakenSpotTrigger::Last,
113            Some(other) => anyhow::bail!(
114                "Unsupported trigger type for Kraken Spot WS: {other:?} (only LastPrice and IndexPrice supported)",
115            ),
116        };
117        order.trigger_price().map(|tp| KrakenWsTriggerParams {
118            reference: trigger_ref,
119            price: tp.as_decimal(),
120            price_type: None,
121        })
122    } else {
123        None
124    };
125
126    if is_conditional && trigger.is_none() {
127        anyhow::bail!("trigger_price is required for conditional order type {order_type:?}");
128    }
129
130    let symbol = cmd.instrument_id.symbol.inner().to_string();
131    let cl_ord_id = Some(truncate_cl_ord_id(&order.client_order_id()));
132    let post_only = order.is_post_only().then_some(true);
133    let reduce_only = order.is_reduce_only().then_some(true);
134
135    Ok(KrakenWsAddOrderParams {
136        order_type: kraken_order_type,
137        side,
138        order_qty: order.quantity().as_decimal(),
139        symbol,
140        token,
141        limit_price,
142        time_in_force: ws_tif,
143        expire_time,
144        cl_ord_id,
145        post_only,
146        reduce_only,
147        leverage,
148        trigger,
149        conditional: None,
150    })
151}
152
153/// Formats a [`UnixNanos`] timestamp as an RFC3339 string suitable for the Kraken
154/// WebSocket v2 `expire_time` field.
155///
156/// `UnixNanos` is a `u64` whose maximum value (`~1.8e19` ns) corresponds to
157/// year 2554, within Jiff's representable range.
158pub(crate) fn format_expire_time(ts: UnixNanos) -> String {
159    ts.to_datetime_utc()
160        .display_with_offset(Offset::UTC)
161        .to_string()
162}
163
164/// Builds WebSocket `amend_order` parameters from a Nautilus modify command.
165///
166/// Prefers `venue_order_id` (as `order_id`) over `client_order_id` (as `cl_ord_id`);
167/// `cmd.client_order_id` is always set so a fallback is always available.
168pub fn build_amend_order_params(cmd: &ModifyOrder, token: String) -> KrakenWsAmendOrderParams {
169    let order_id = cmd.venue_order_id.as_ref().map(|id| id.to_string());
170    let cl_ord_id = if order_id.is_none() {
171        Some(truncate_cl_ord_id(&cmd.client_order_id))
172    } else {
173        None
174    };
175
176    KrakenWsAmendOrderParams {
177        token,
178        order_id,
179        cl_ord_id,
180        order_qty: cmd.quantity.map(|q| q.as_decimal()),
181        limit_price: cmd.price.map(|p| p.as_decimal()),
182        trigger_price: cmd.trigger_price.map(|p| p.as_decimal()),
183    }
184}
185
186/// Builds WebSocket `cancel_order` parameters from a Nautilus cancel command.
187///
188/// Prefers `venue_order_id` (as `order_id`) over `client_order_id` (as `cl_ord_id`),
189/// mirroring the REST cancel path which prefers the venue identifier since Kraken
190/// always knows it.
191pub fn build_cancel_order_params(cmd: &CancelOrder, token: String) -> KrakenWsCancelOrderParams {
192    if let Some(ref venue_id) = cmd.venue_order_id {
193        KrakenWsCancelOrderParams {
194            token,
195            order_id: Some(vec![venue_id.to_string()]),
196            cl_ord_id: None,
197        }
198    } else {
199        KrakenWsCancelOrderParams {
200            token,
201            order_id: None,
202            cl_ord_id: Some(vec![truncate_cl_ord_id(&cmd.client_order_id)]),
203        }
204    }
205}
206
207pub(crate) fn compute_ws_time_in_force(
208    is_limit_order: bool,
209    time_in_force: TimeInForce,
210    expire_time: Option<UnixNanos>,
211) -> anyhow::Result<Option<KrakenTimeInForce>> {
212    if !is_limit_order {
213        return Ok(None);
214    }
215
216    match time_in_force {
217        TimeInForce::Gtc => Ok(None),
218        TimeInForce::Ioc => Ok(Some(KrakenTimeInForce::ImmediateOrCancel)),
219        TimeInForce::Fok => {
220            anyhow::bail!("FOK time in force is not supported on Kraken WS v2; use REST")
221        }
222        TimeInForce::Gtd => {
223            expire_time.ok_or_else(|| {
224                anyhow::anyhow!("GTD time in force requires expire_time parameter")
225            })?;
226            Ok(Some(KrakenTimeInForce::GoodTilDate))
227        }
228        _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use nautilus_core::{UUID4, UnixNanos};
235    use nautilus_model::identifiers::{
236        ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId,
237    };
238    use rstest::rstest;
239    use rust_decimal_macros::dec;
240
241    use super::*;
242
243    fn make_cancel_order(client_order_id: &str, venue_order_id: Option<&str>) -> CancelOrder {
244        CancelOrder {
245            trader_id: TraderId::from("TESTER-001"),
246            client_id: None,
247            strategy_id: StrategyId::from("S-001"),
248            instrument_id: InstrumentId::from("XBT/USD.KRAKEN"),
249            client_order_id: ClientOrderId::from(client_order_id),
250            venue_order_id: venue_order_id.map(VenueOrderId::new),
251            command_id: UUID4::new(),
252            ts_init: UnixNanos::default(),
253            params: None,
254            correlation_id: None,
255            causation_id: None,
256        }
257    }
258
259    #[rstest]
260    fn test_format_expire_time_rfc3339() {
261        let ts = UnixNanos::from(1_767_225_599_000_000_000_u64);
262        assert_eq!(format_expire_time(ts), "2025-12-31T23:59:59+00:00");
263    }
264
265    #[rstest]
266    fn test_format_expire_time_max_unix_nanos_handled() {
267        // u64::MAX nanos is approximately year 2554 and is representable. Documents that the
268        // function never panics for any in-range UnixNanos value.
269        let ts = UnixNanos::from(u64::MAX);
270        let formatted = format_expire_time(ts);
271        assert!(formatted.starts_with("2554-"), "unexpected: {formatted}");
272    }
273
274    #[rstest]
275    fn test_build_add_order_params_trailing_stop_market_bails() {
276        use nautilus_model::{
277            enums::TrailingOffsetType,
278            orders::trailing_stop_market::TrailingStopMarketOrder,
279            types::{Price, Quantity},
280        };
281        use rust_decimal::Decimal;
282
283        let trader_id = TraderId::from("TESTER-001");
284        let strategy_id = StrategyId::from("S-001");
285        let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
286        let cl_ord_id = ClientOrderId::from("O-1");
287
288        let order = OrderAny::TrailingStopMarket(TrailingStopMarketOrder::new(
289            trader_id,
290            strategy_id,
291            instrument_id,
292            cl_ord_id,
293            OrderSide::Buy,
294            Quantity::from("0.01"),
295            None,
296            Price::from("50000.00"),
297            TriggerType::LastPrice,
298            Decimal::new(100, 0),
299            TrailingOffsetType::Price,
300            TimeInForce::Gtc,
301            None,
302            false,
303            false,
304            None,
305            None,
306            None,
307            None,
308            None,
309            None,
310            None,
311            None,
312            None,
313            None,
314            None,
315            UUID4::new(),
316            UnixNanos::default(),
317        ));
318
319        let cmd = SubmitOrder {
320            trader_id,
321            client_id: None,
322            strategy_id,
323            instrument_id,
324            client_order_id: cl_ord_id,
325            order_init: order.init_event().clone(),
326            exec_algorithm_id: None,
327            position_id: None,
328            params: None,
329            command_id: UUID4::new(),
330            ts_init: UnixNanos::default(),
331            correlation_id: None,
332            causation_id: None,
333        };
334
335        let err = build_add_order_params(&cmd, &order, "TKN".to_string(), None)
336            .expect_err("TrailingStopMarket must bail to REST");
337        let msg = format!("{err}");
338        assert!(
339            msg.contains("Trailing stop") && msg.contains("REST"),
340            "unexpected error: {msg}",
341        );
342    }
343
344    #[rstest]
345    fn test_build_add_order_params_iceberg_bails() {
346        use nautilus_model::{
347            orders::limit::LimitOrder,
348            types::{Price, Quantity},
349        };
350
351        let trader_id = TraderId::from("TESTER-001");
352        let strategy_id = StrategyId::from("S-001");
353        let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
354        let cl_ord_id = ClientOrderId::from("O-1");
355
356        let order = OrderAny::Limit(LimitOrder::new(
357            trader_id,
358            strategy_id,
359            instrument_id,
360            cl_ord_id,
361            OrderSide::Buy,
362            Quantity::from("1.0"),
363            Price::from("50000.00"),
364            TimeInForce::Gtc,
365            None,
366            false,
367            false,
368            false,
369            Some(Quantity::from("0.1")), // display_qty -> iceberg
370            None,
371            None,
372            None,
373            None,
374            None,
375            None,
376            None,
377            None,
378            None,
379            None,
380            UUID4::new(),
381            UnixNanos::default(),
382        ));
383
384        let cmd = SubmitOrder {
385            trader_id,
386            client_id: None,
387            strategy_id,
388            instrument_id,
389            client_order_id: cl_ord_id,
390            order_init: order.init_event().clone(),
391            exec_algorithm_id: None,
392            position_id: None,
393            params: None,
394            command_id: UUID4::new(),
395            ts_init: UnixNanos::default(),
396            correlation_id: None,
397            causation_id: None,
398        };
399
400        let err = build_add_order_params(&cmd, &order, "TKN".to_string(), None)
401            .expect_err("Iceberg orders must bail to REST");
402        let msg = format!("{err}");
403        assert!(
404            msg.contains("Iceberg") && msg.contains("REST"),
405            "unexpected error: {msg}",
406        );
407    }
408
409    #[rstest]
410    fn test_build_add_order_params_unsupported_trigger_type_bails() {
411        use nautilus_model::{
412            orders::stop_market::StopMarketOrder,
413            types::{Price, Quantity},
414        };
415
416        let trader_id = TraderId::from("TESTER-001");
417        let strategy_id = StrategyId::from("S-001");
418        let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
419        let cl_ord_id = ClientOrderId::from("O-1");
420
421        let order = OrderAny::StopMarket(StopMarketOrder::new(
422            trader_id,
423            strategy_id,
424            instrument_id,
425            cl_ord_id,
426            OrderSide::Buy,
427            Quantity::from("0.01"),
428            Price::from("50000.00"),
429            TriggerType::MarkPrice,
430            TimeInForce::Gtc,
431            None,  // expire_time
432            false, // reduce_only
433            false, // quote_quantity
434            None,  // display_qty
435            None,  // emulation_trigger
436            None,  // trigger_instrument_id
437            None,  // contingency_type
438            None,  // order_list_id
439            None,  // linked_order_ids
440            None,  // parent_order_id
441            None,  // exec_algorithm_id
442            None,  // exec_algorithm_params
443            None,  // exec_spawn_id
444            None,  // tags
445            UUID4::new(),
446            UnixNanos::default(),
447        ));
448
449        let cmd = SubmitOrder {
450            trader_id,
451            client_id: None,
452            strategy_id,
453            instrument_id,
454            client_order_id: cl_ord_id,
455            order_init: order.init_event().clone(),
456            exec_algorithm_id: None,
457            position_id: None,
458            params: None,
459            command_id: UUID4::new(),
460            ts_init: UnixNanos::default(),
461            correlation_id: None,
462            causation_id: None,
463        };
464
465        let err = build_add_order_params(&cmd, &order, "TKN".to_string(), None)
466            .expect_err("MarkPrice trigger must bail to REST");
467        let msg = format!("{err}");
468        assert!(
469            msg.contains("trigger type") && msg.contains("LastPrice"),
470            "unexpected error: {msg}",
471        );
472    }
473
474    #[rstest]
475    fn test_compute_ws_time_in_force_fok_bails() {
476        let result = compute_ws_time_in_force(true, TimeInForce::Fok, None);
477        let err = result.expect_err("FOK should bail on WS path");
478        let msg = format!("{err}");
479        assert!(
480            msg.contains("FOK") && msg.contains("REST"),
481            "unexpected error: {msg}",
482        );
483    }
484
485    #[rstest]
486    fn test_build_cancel_order_params_with_venue_id() {
487        let cmd = make_cancel_order("O-20260505-001", Some("OABCDE-12345-FGHIJ"));
488        let params = build_cancel_order_params(&cmd, "TOKEN".to_string());
489
490        let ids = params.order_id.as_ref().unwrap();
491        assert_eq!(ids, &["OABCDE-12345-FGHIJ"]);
492        assert!(params.cl_ord_id.is_none());
493    }
494
495    #[rstest]
496    fn test_build_cancel_order_params_falls_back_to_client_id() {
497        let cmd = make_cancel_order("O-20260505-001", None);
498        let params = build_cancel_order_params(&cmd, "TOKEN".to_string());
499
500        assert!(params.order_id.is_none());
501        let cl_ord_ids = params.cl_ord_id.as_ref().unwrap();
502        assert_eq!(cl_ord_ids, &["O-20260505-001"]);
503    }
504
505    #[rstest]
506    fn test_build_cancel_order_params_long_client_id_is_truncated() {
507        let cmd = make_cancel_order("O202602270023210040011", None);
508        let params = build_cancel_order_params(&cmd, "TOKEN".to_string());
509
510        assert!(params.order_id.is_none());
511        let cl_ord_ids = params.cl_ord_id.as_ref().unwrap();
512        assert_eq!(cl_ord_ids.len(), 1);
513        let cl_ord_id = &cl_ord_ids[0];
514        assert!(
515            cl_ord_id.len() <= 18,
516            "cl_ord_id length was {}",
517            cl_ord_id.len()
518        );
519    }
520
521    #[rstest]
522    fn test_build_amend_order_params_with_venue_id() {
523        use nautilus_model::types::{Price, Quantity};
524
525        let cmd = ModifyOrder {
526            trader_id: TraderId::from("TESTER-001"),
527            client_id: None,
528            strategy_id: StrategyId::from("S-001"),
529            instrument_id: InstrumentId::from("XBT/USD.KRAKEN"),
530            client_order_id: ClientOrderId::from("O-001"),
531            venue_order_id: Some(VenueOrderId::new("OABCDE-12345-FGHIJ")),
532            quantity: Some(Quantity::from("0.1")),
533            price: Some(Price::from("50000.0")),
534            trigger_price: None,
535            command_id: UUID4::new(),
536            ts_init: UnixNanos::default(),
537            params: None,
538            correlation_id: None,
539            causation_id: None,
540        };
541
542        let params = build_amend_order_params(&cmd, "TOKEN".to_string());
543
544        assert_eq!(params.order_id.as_deref(), Some("OABCDE-12345-FGHIJ"));
545        assert!(params.cl_ord_id.is_none());
546        assert_eq!(params.order_qty, Some(dec!(0.1)));
547        assert_eq!(params.limit_price, Some(dec!(50000)));
548        assert!(params.trigger_price.is_none());
549    }
550
551    #[rstest]
552    fn test_build_amend_order_params_falls_back_to_client_id() {
553        use nautilus_model::types::Quantity;
554
555        let cmd = ModifyOrder {
556            trader_id: TraderId::from("TESTER-001"),
557            client_id: None,
558            strategy_id: StrategyId::from("S-001"),
559            instrument_id: InstrumentId::from("XBT/USD.KRAKEN"),
560            client_order_id: ClientOrderId::from("O-001"),
561            venue_order_id: None,
562            quantity: Some(Quantity::from("0.2")),
563            price: None,
564            trigger_price: None,
565            command_id: UUID4::new(),
566            ts_init: UnixNanos::default(),
567            params: None,
568            correlation_id: None,
569            causation_id: None,
570        };
571
572        let params = build_amend_order_params(&cmd, "TOKEN".to_string());
573
574        assert!(params.order_id.is_none());
575        assert_eq!(params.cl_ord_id.as_deref(), Some("O-001"));
576    }
577}