1use 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
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 _ => 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
154pub(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
169pub 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
191pub 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 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")), 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, false, false, None, None, None, None, None, None, None, None, None, None, None, 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}