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