1use 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
37pub 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
153pub(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
165pub 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
190pub 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 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")), 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, false, false, None, None, None, None, None, None, None, None, None, None, None, 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}