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, string::secret::SecretString};
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: SecretString,
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(expire_time: UnixNanos) -> String {
159    expire_time
160        .to_datetime_utc()
161        .display_with_offset(Offset::UTC)
162        .to_string()
163}
164
165/// Builds WebSocket `amend_order` parameters from a Nautilus modify command.
166///
167/// Prefers `venue_order_id` (as `order_id`) over `client_order_id` (as `cl_ord_id`);
168/// `cmd.client_order_id` is always set so a fallback is always available.
169pub fn build_amend_order_params(
170    cmd: &ModifyOrder,
171    token: SecretString,
172) -> KrakenWsAmendOrderParams {
173    let order_id = cmd.venue_order_id.as_ref().map(|id| id.to_string());
174    let cl_ord_id = if order_id.is_none() {
175        Some(truncate_cl_ord_id(&cmd.client_order_id))
176    } else {
177        None
178    };
179
180    KrakenWsAmendOrderParams {
181        token,
182        order_id,
183        cl_ord_id,
184        order_qty: cmd.quantity.map(|q| q.as_decimal()),
185        limit_price: cmd.price.map(|p| p.as_decimal()),
186        trigger_price: cmd.trigger_price.map(|p| p.as_decimal()),
187    }
188}
189
190/// Builds WebSocket `cancel_order` parameters from a Nautilus cancel command.
191///
192/// Prefers `venue_order_id` (as `order_id`) over `client_order_id` (as `cl_ord_id`),
193/// mirroring the REST cancel path which prefers the venue identifier since Kraken
194/// always knows it.
195pub fn build_cancel_order_params(
196    cmd: &CancelOrder,
197    token: SecretString,
198) -> KrakenWsCancelOrderParams {
199    if let Some(ref venue_id) = cmd.venue_order_id {
200        KrakenWsCancelOrderParams {
201            token,
202            order_id: Some(vec![venue_id.to_string()]),
203            cl_ord_id: None,
204        }
205    } else {
206        KrakenWsCancelOrderParams {
207            token,
208            order_id: None,
209            cl_ord_id: Some(vec![truncate_cl_ord_id(&cmd.client_order_id)]),
210        }
211    }
212}
213
214pub(crate) fn compute_ws_time_in_force(
215    is_limit_order: bool,
216    time_in_force: TimeInForce,
217    expire_time: Option<UnixNanos>,
218) -> anyhow::Result<Option<KrakenTimeInForce>> {
219    if !is_limit_order {
220        return Ok(None);
221    }
222
223    match time_in_force {
224        TimeInForce::Gtc => Ok(None),
225        TimeInForce::Ioc => Ok(Some(KrakenTimeInForce::ImmediateOrCancel)),
226        TimeInForce::Fok => {
227            anyhow::bail!("FOK time in force is not supported on Kraken WS v2; use REST")
228        }
229        TimeInForce::Gtd => {
230            expire_time.ok_or_else(|| {
231                anyhow::anyhow!("GTD time in force requires expire_time parameter")
232            })?;
233            Ok(Some(KrakenTimeInForce::GoodTilDate))
234        }
235        _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use nautilus_core::{UUID4, UnixNanos};
242    use nautilus_model::identifiers::{
243        ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId,
244    };
245    use rstest::rstest;
246    use rust_decimal_macros::dec;
247
248    use super::*;
249
250    fn make_cancel_order(client_order_id: &str, venue_order_id: Option<&str>) -> CancelOrder {
251        CancelOrder {
252            trader_id: TraderId::from("TESTER-001"),
253            client_id: None,
254            strategy_id: StrategyId::from("S-001"),
255            instrument_id: InstrumentId::from("XBT/USD.KRAKEN"),
256            client_order_id: ClientOrderId::from(client_order_id),
257            venue_order_id: venue_order_id.map(VenueOrderId::new),
258            command_id: UUID4::new(),
259            ts_init: UnixNanos::default(),
260            params: None,
261            correlation_id: None,
262            causation_id: None,
263        }
264    }
265
266    #[rstest]
267    fn test_format_expire_time_rfc3339() {
268        let ts = UnixNanos::from(1_767_225_599_000_000_000_u64);
269        assert_eq!(format_expire_time(ts), "2025-12-31T23:59:59+00:00");
270    }
271
272    #[rstest]
273    fn test_format_expire_time_max_unix_nanos_handled() {
274        // u64::MAX nanos is approximately year 2554 and is representable. Documents that the
275        // function never panics for any in-range UnixNanos value.
276        let ts = UnixNanos::from(u64::MAX);
277        let formatted = format_expire_time(ts);
278        assert!(formatted.starts_with("2554-"), "unexpected: {formatted}");
279    }
280
281    #[rstest]
282    fn test_build_add_order_params_trailing_stop_market_bails() {
283        use nautilus_model::{
284            enums::TrailingOffsetType,
285            orders::trailing_stop_market::TrailingStopMarketOrder,
286            types::{Price, Quantity},
287        };
288        use rust_decimal::Decimal;
289
290        let trader_id = TraderId::from("TESTER-001");
291        let strategy_id = StrategyId::from("S-001");
292        let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
293        let cl_ord_id = ClientOrderId::from("O-1");
294
295        let order = OrderAny::TrailingStopMarket(TrailingStopMarketOrder::new(
296            trader_id,
297            strategy_id,
298            instrument_id,
299            cl_ord_id,
300            OrderSide::Buy,
301            Quantity::from("0.01"),
302            None,
303            Price::from("50000.00"),
304            TriggerType::LastPrice,
305            Decimal::new(100, 0),
306            TrailingOffsetType::Price,
307            TimeInForce::Gtc,
308            None,
309            false,
310            false,
311            None,
312            None,
313            None,
314            None,
315            None,
316            None,
317            None,
318            None,
319            None,
320            None,
321            None,
322            UUID4::new(),
323            UnixNanos::default(),
324        ));
325
326        let cmd = SubmitOrder {
327            trader_id,
328            client_id: None,
329            strategy_id,
330            instrument_id,
331            client_order_id: cl_ord_id,
332            order_init: order.init_event().clone(),
333            exec_algorithm_id: None,
334            position_id: None,
335            params: None,
336            command_id: UUID4::new(),
337            ts_init: UnixNanos::default(),
338            correlation_id: None,
339            causation_id: None,
340        };
341
342        let err = build_add_order_params(&cmd, &order, SecretString::from("TKN"), None)
343            .expect_err("TrailingStopMarket must bail to REST");
344        let msg = format!("{err}");
345        assert!(
346            msg.contains("Trailing stop") && msg.contains("REST"),
347            "unexpected error: {msg}",
348        );
349    }
350
351    #[rstest]
352    fn test_build_add_order_params_iceberg_bails() {
353        use nautilus_model::{
354            orders::limit::LimitOrder,
355            types::{Price, Quantity},
356        };
357
358        let trader_id = TraderId::from("TESTER-001");
359        let strategy_id = StrategyId::from("S-001");
360        let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
361        let cl_ord_id = ClientOrderId::from("O-1");
362
363        let order = OrderAny::Limit(LimitOrder::new(
364            trader_id,
365            strategy_id,
366            instrument_id,
367            cl_ord_id,
368            OrderSide::Buy,
369            Quantity::from("1.0"),
370            Price::from("50000.00"),
371            TimeInForce::Gtc,
372            None,
373            false,
374            false,
375            false,
376            Some(Quantity::from("0.1")), // display_qty -> iceberg
377            None,
378            None,
379            None,
380            None,
381            None,
382            None,
383            None,
384            None,
385            None,
386            None,
387            UUID4::new(),
388            UnixNanos::default(),
389        ));
390
391        let cmd = SubmitOrder {
392            trader_id,
393            client_id: None,
394            strategy_id,
395            instrument_id,
396            client_order_id: cl_ord_id,
397            order_init: order.init_event().clone(),
398            exec_algorithm_id: None,
399            position_id: None,
400            params: None,
401            command_id: UUID4::new(),
402            ts_init: UnixNanos::default(),
403            correlation_id: None,
404            causation_id: None,
405        };
406
407        let err = build_add_order_params(&cmd, &order, SecretString::from("TKN"), None)
408            .expect_err("Iceberg orders must bail to REST");
409        let msg = format!("{err}");
410        assert!(
411            msg.contains("Iceberg") && msg.contains("REST"),
412            "unexpected error: {msg}",
413        );
414    }
415
416    #[rstest]
417    fn test_build_add_order_params_unsupported_trigger_type_bails() {
418        use nautilus_model::{
419            orders::stop_market::StopMarketOrder,
420            types::{Price, Quantity},
421        };
422
423        let trader_id = TraderId::from("TESTER-001");
424        let strategy_id = StrategyId::from("S-001");
425        let instrument_id = InstrumentId::from("BTC/USD.KRAKEN");
426        let cl_ord_id = ClientOrderId::from("O-1");
427
428        let order = OrderAny::StopMarket(StopMarketOrder::new(
429            trader_id,
430            strategy_id,
431            instrument_id,
432            cl_ord_id,
433            OrderSide::Buy,
434            Quantity::from("0.01"),
435            Price::from("50000.00"),
436            TriggerType::MarkPrice,
437            TimeInForce::Gtc,
438            None,  // expire_time
439            false, // reduce_only
440            false, // quote_quantity
441            None,  // display_qty
442            None,  // emulation_trigger
443            None,  // trigger_instrument_id
444            None,  // contingency_type
445            None,  // order_list_id
446            None,  // linked_order_ids
447            None,  // parent_order_id
448            None,  // exec_algorithm_id
449            None,  // exec_algorithm_params
450            None,  // exec_spawn_id
451            None,  // tags
452            UUID4::new(),
453            UnixNanos::default(),
454        ));
455
456        let cmd = SubmitOrder {
457            trader_id,
458            client_id: None,
459            strategy_id,
460            instrument_id,
461            client_order_id: cl_ord_id,
462            order_init: order.init_event().clone(),
463            exec_algorithm_id: None,
464            position_id: None,
465            params: None,
466            command_id: UUID4::new(),
467            ts_init: UnixNanos::default(),
468            correlation_id: None,
469            causation_id: None,
470        };
471
472        let err = build_add_order_params(&cmd, &order, SecretString::from("TKN"), None)
473            .expect_err("MarkPrice trigger must bail to REST");
474        let msg = format!("{err}");
475        assert!(
476            msg.contains("trigger type") && msg.contains("LastPrice"),
477            "unexpected error: {msg}",
478        );
479    }
480
481    #[rstest]
482    fn test_compute_ws_time_in_force_fok_bails() {
483        let result = compute_ws_time_in_force(true, TimeInForce::Fok, None);
484        let err = result.expect_err("FOK should bail on WS path");
485        let msg = format!("{err}");
486        assert!(
487            msg.contains("FOK") && msg.contains("REST"),
488            "unexpected error: {msg}",
489        );
490    }
491
492    #[rstest]
493    fn test_build_cancel_order_params_with_venue_id() {
494        let cmd = make_cancel_order("O-20260505-001", Some("OABCDE-12345-FGHIJ"));
495        let params = build_cancel_order_params(&cmd, SecretString::from("TOKEN"));
496
497        let ids = params.order_id.as_ref().unwrap();
498        assert_eq!(ids, &["OABCDE-12345-FGHIJ"]);
499        assert!(params.cl_ord_id.is_none());
500    }
501
502    #[rstest]
503    fn test_build_cancel_order_params_falls_back_to_client_id() {
504        let cmd = make_cancel_order("O-20260505-001", None);
505        let params = build_cancel_order_params(&cmd, SecretString::from("TOKEN"));
506
507        assert!(params.order_id.is_none());
508        let cl_ord_ids = params.cl_ord_id.as_ref().unwrap();
509        assert_eq!(cl_ord_ids, &["O-20260505-001"]);
510    }
511
512    #[rstest]
513    fn test_build_cancel_order_params_long_client_id_is_truncated() {
514        let cmd = make_cancel_order("O202602270023210040011", None);
515        let params = build_cancel_order_params(&cmd, SecretString::from("TOKEN"));
516
517        assert!(params.order_id.is_none());
518        let cl_ord_ids = params.cl_ord_id.as_ref().unwrap();
519        assert_eq!(cl_ord_ids.len(), 1);
520        let cl_ord_id = &cl_ord_ids[0];
521        assert!(
522            cl_ord_id.len() <= 18,
523            "cl_ord_id length was {}",
524            cl_ord_id.len()
525        );
526    }
527
528    #[rstest]
529    fn test_build_amend_order_params_with_venue_id() {
530        use nautilus_model::types::{Price, Quantity};
531
532        let cmd = ModifyOrder {
533            trader_id: TraderId::from("TESTER-001"),
534            client_id: None,
535            strategy_id: StrategyId::from("S-001"),
536            instrument_id: InstrumentId::from("XBT/USD.KRAKEN"),
537            client_order_id: ClientOrderId::from("O-001"),
538            venue_order_id: Some(VenueOrderId::new("OABCDE-12345-FGHIJ")),
539            quantity: Some(Quantity::from("0.1")),
540            price: Some(Price::from("50000.0")),
541            trigger_price: None,
542            command_id: UUID4::new(),
543            ts_init: UnixNanos::default(),
544            params: None,
545            correlation_id: None,
546            causation_id: None,
547        };
548
549        let params = build_amend_order_params(&cmd, SecretString::from("TOKEN"));
550
551        assert_eq!(params.order_id.as_deref(), Some("OABCDE-12345-FGHIJ"));
552        assert!(params.cl_ord_id.is_none());
553        assert_eq!(params.order_qty, Some(dec!(0.1)));
554        assert_eq!(params.limit_price, Some(dec!(50000)));
555        assert!(params.trigger_price.is_none());
556    }
557
558    #[rstest]
559    fn test_build_amend_order_params_falls_back_to_client_id() {
560        use nautilus_model::types::Quantity;
561
562        let cmd = ModifyOrder {
563            trader_id: TraderId::from("TESTER-001"),
564            client_id: None,
565            strategy_id: StrategyId::from("S-001"),
566            instrument_id: InstrumentId::from("XBT/USD.KRAKEN"),
567            client_order_id: ClientOrderId::from("O-001"),
568            venue_order_id: None,
569            quantity: Some(Quantity::from("0.2")),
570            price: None,
571            trigger_price: None,
572            command_id: UUID4::new(),
573            ts_init: UnixNanos::default(),
574            params: None,
575            correlation_id: None,
576            causation_id: None,
577        };
578
579        let params = build_amend_order_params(&cmd, SecretString::from("TOKEN"));
580
581        assert!(params.order_id.is_none());
582        assert_eq!(params.cl_ord_id.as_deref(), Some("O-001"));
583    }
584}