1use 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
37pub 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
153pub(crate) fn format_expire_time(ts: UnixNanos) -> String {
159 ts.to_datetime_utc()
160 .display_with_offset(Offset::UTC)
161 .to_string()
162}
163
164pub 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
186pub 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 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")), 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, false, false, None, None, None, None, None, None, None, None, None, None, None, 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}