Skip to main content

nautilus_kraken/http/spot/
query.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//! Query parameter structs for Kraken Spot HTTP API requests.
17
18use derive_builder::Builder;
19use serde::{Deserialize, Serialize};
20use ustr::Ustr;
21
22use crate::common::enums::{KrakenAssetClass, KrakenOrderSide, KrakenOrderType};
23
24/// Parameters for adding an order via `POST /0/private/AddOrder`.
25///
26/// # References
27/// - <https://docs.kraken.com/api/docs/rest-api/add-order>
28#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
29#[builder(setter(into, strip_option), build_fn(validate = "Self::validate"))]
30pub struct KrakenSpotAddOrderParams {
31    /// Asset pair (e.g., "XXBTZUSD").
32    pub pair: Ustr,
33
34    /// Order side: "buy" or "sell".
35    #[serde(rename = "type")]
36    pub side: KrakenOrderSide,
37
38    /// Order type: market, limit, stop-loss, etc.
39    #[serde(rename = "ordertype")]
40    pub order_type: KrakenOrderType,
41
42    /// Order quantity in base currency.
43    pub volume: String,
44
45    /// Limit price (required for limit orders).
46    #[builder(default)]
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub price: Option<String>,
49
50    /// Secondary price for stop-loss-limit and take-profit-limit.
51    #[builder(default)]
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub price2: Option<String>,
54
55    /// Client order ID (must be UUID format).
56    #[builder(default)]
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub cl_ord_id: Option<String>,
59
60    /// Order flags (comma-separated: post, fcib, fciq, nompp, viqc).
61    #[builder(default)]
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub oflags: Option<String>,
64
65    /// Time in force: GTC, IOC, GTD.
66    #[builder(default)]
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub timeinforce: Option<String>,
69
70    /// Expiration time for GTD orders (Unix timestamp or `+<seconds>`).
71    #[builder(default)]
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub expiretm: Option<String>,
74
75    /// Trigger reference for conditional orders: "last" or "index".
76    #[builder(default)]
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub trigger: Option<String>,
79
80    /// Display volume for iceberg orders.
81    #[builder(default)]
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub displayvol: Option<String>,
84
85    /// Partner/broker attribution ID.
86    #[builder(default)]
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub broker: Option<Ustr>,
89
90    /// Asset class override for tokenized assets (xStocks).
91    #[builder(default)]
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub asset_class: Option<KrakenAssetClass>,
94
95    /// Leverage ratio for spot margin orders (e.g., `"2:1"`, `"3:1"`, `"5:1"`).
96    /// Omit for cash (non-margin) orders.
97    /// Valid tiers per pair are in `AssetPairInfo.leverage_buy` / `leverage_sell`.
98    #[builder(default)]
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub leverage: Option<String>,
101
102    /// If `true`, the order will only reduce an existing margin position.
103    /// Kraken rejects the order if no open position exists for the pair.
104    #[builder(default)]
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub reduce_only: Option<bool>,
107}
108
109impl KrakenSpotAddOrderParamsBuilder {
110    fn validate(&self) -> Result<(), String> {
111        // Validate price is present for limit-type orders
112        if let Some(
113            KrakenOrderType::Limit
114            | KrakenOrderType::StopLossLimit
115            | KrakenOrderType::TakeProfitLimit,
116        ) = self.order_type
117            && (self.price.is_none() || self.price.as_ref().unwrap().is_none())
118        {
119            return Err("price is required for limit orders".to_string());
120        }
121
122        // Validate price2 (limit price) is present for stop-loss-limit and take-profit-limit
123        if let Some(KrakenOrderType::StopLossLimit | KrakenOrderType::TakeProfitLimit) =
124            self.order_type
125            && (self.price2.is_none() || self.price2.as_ref().unwrap().is_none())
126        {
127            return Err(
128                "price2 (limit price) is required for stop-loss-limit and take-profit-limit orders"
129                    .to_string(),
130            );
131        }
132        Ok(())
133    }
134}
135
136/// A single order payload for `POST /0/private/AddOrderBatch`.
137///
138/// This mirrors `KrakenSpotAddOrderParams` without the shared top-level `pair`
139/// and broker fields.
140#[derive(Clone, Debug, Serialize, Deserialize)]
141pub struct KrakenSpotBatchOrderParams {
142    /// Order side: "buy" or "sell".
143    #[serde(rename = "type")]
144    pub side: KrakenOrderSide,
145
146    /// Order type: market, limit, stop-loss, etc.
147    #[serde(rename = "ordertype")]
148    pub order_type: KrakenOrderType,
149
150    /// Order quantity in base currency.
151    pub volume: String,
152
153    /// Limit price or trigger price, depending on order type.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub price: Option<String>,
156
157    /// Secondary limit price for supported conditional orders.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub price2: Option<String>,
160
161    /// Client order ID.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub cl_ord_id: Option<String>,
164
165    /// Order flags (comma-separated: post, fcib, fciq, nompp, viqc).
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub oflags: Option<String>,
168
169    /// Time in force: GTC, IOC, GTD.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub timeinforce: Option<String>,
172
173    /// Expiration time for GTD orders.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub expiretm: Option<String>,
176
177    /// Trigger reference for conditional orders: "last" or "index".
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub trigger: Option<String>,
180
181    /// Display volume for iceberg orders.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub displayvol: Option<String>,
184
185    /// Leverage ratio for spot margin orders (e.g. `"3:1"`). Omit for cash orders.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub leverage: Option<String>,
188
189    /// If `true`, the order will only reduce an existing margin position.
190    /// Kraken rejects the order if no open position exists for the pair.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub reduce_only: Option<bool>,
193}
194
195impl From<KrakenSpotAddOrderParams> for KrakenSpotBatchOrderParams {
196    fn from(params: KrakenSpotAddOrderParams) -> Self {
197        Self {
198            side: params.side,
199            order_type: params.order_type,
200            volume: params.volume,
201            price: params.price,
202            price2: params.price2,
203            cl_ord_id: params.cl_ord_id,
204            oflags: params.oflags,
205            timeinforce: params.timeinforce,
206            expiretm: params.expiretm,
207            trigger: params.trigger,
208            displayvol: params.displayvol,
209            leverage: params.leverage,
210            reduce_only: params.reduce_only,
211        }
212    }
213}
214
215/// Parameters for batch adding orders via `POST /0/private/AddOrderBatch`.
216///
217/// # References
218/// - <https://docs.kraken.com/api/docs/rest-api/add-order-batch>
219#[derive(Clone, Debug, Serialize, Deserialize)]
220pub struct KrakenSpotAddOrderBatchParams {
221    /// Asset pair shared across all orders in the batch.
222    pub pair: Ustr,
223
224    /// List of orders to submit for that pair.
225    pub orders: Vec<KrakenSpotBatchOrderParams>,
226
227    /// Asset class override for tokenized assets (xStocks).
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub asset_class: Option<KrakenAssetClass>,
230}
231
232/// Parameters for cancelling an order via `POST /0/private/CancelOrder`.
233///
234/// # References
235/// - <https://docs.kraken.com/api/docs/rest-api/cancel-order>
236#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
237#[builder(setter(into, strip_option))]
238pub struct KrakenSpotCancelOrderParams {
239    /// Transaction ID (venue order ID) to cancel.
240    /// Note: The Kraken v0 API uses `txid` as the parameter name.
241    #[builder(default)]
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub txid: Option<String>,
244
245    /// Client order ID to cancel.
246    #[builder(default)]
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub cl_ord_id: Option<String>,
249}
250
251/// Parameters for batch cancelling orders via `POST /0/private/CancelOrderBatch`.
252///
253/// # References
254/// - <https://docs.kraken.com/api/docs/rest-api/cancel-order-batch>
255#[derive(Clone, Debug, Serialize, Deserialize)]
256pub struct KrakenSpotCancelOrderBatchParams {
257    /// Transaction IDs (venue order IDs) to cancel.
258    ///
259    /// Kraken accepts transaction IDs and user references here, not client order IDs.
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub orders: Vec<String>,
262    /// Client order IDs to cancel, which Kraken requires in this separate field.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub cl_ord_ids: Vec<String>,
265}
266
267/// Parameters for editing an order via `POST /0/private/EditOrder`.
268///
269/// Note: Consider using `KrakenSpotAmendOrderParams` with `AmendOrder` instead,
270/// which is faster and keeps queue priority.
271///
272/// # References
273/// - <https://docs.kraken.com/api/docs/rest-api/edit-order>
274#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
275#[builder(setter(into, strip_option))]
276pub struct KrakenSpotEditOrderParams {
277    /// Asset pair (e.g., "XXBTZUSD"). Required.
278    pub pair: Ustr,
279
280    /// Transaction ID (venue order ID) of the order to edit.
281    #[builder(default)]
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub txid: Option<String>,
284
285    /// Client order ID of the order to edit. Note: Not supported by Kraken EditOrder.
286    #[builder(default)]
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub cl_ord_id: Option<String>,
289
290    /// New order quantity in base currency.
291    #[builder(default)]
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub volume: Option<String>,
294
295    /// New limit price.
296    #[builder(default)]
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub price: Option<String>,
299
300    /// New secondary price for stop-loss-limit and take-profit-limit.
301    #[builder(default)]
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub price2: Option<String>,
304}
305
306/// Parameters for amending an order via `POST /0/private/AmendOrder`.
307///
308/// This is Kraken's atomic amend endpoint which modifies order parameters
309/// in-place without cancelling the original order. Faster and keeps queue priority.
310///
311/// # References
312/// - <https://docs.kraken.com/api/docs/rest-api/amend-order>
313#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
314#[builder(setter(into, strip_option))]
315pub struct KrakenSpotAmendOrderParams {
316    /// Transaction ID (venue order ID) of the order to amend.
317    #[builder(default)]
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub txid: Option<String>,
320
321    /// Client order ID of the order to amend.
322    #[builder(default)]
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub cl_ord_id: Option<String>,
325
326    /// New order quantity in base currency.
327    #[builder(default)]
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub order_qty: Option<String>,
330
331    /// New limit price.
332    #[builder(default)]
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub limit_price: Option<String>,
335
336    /// New trigger price for stop/conditional orders.
337    #[builder(default)]
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub trigger_price: Option<String>,
340}
341
342/// Parameters for `POST /0/private/OpenPositions`.
343#[derive(Clone, Debug, Default, Serialize, Deserialize)]
344pub struct SpotOpenPositionsParams {
345    /// Filter to a specific position by transaction ID.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub txid: Option<String>,
348
349    /// If `true`, include profit/loss calculations (`value` and `net` fields) in response.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub docalcs: Option<bool>,
352
353    /// `"market"` to consolidate positions by market.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub consolidation: Option<String>,
356}
357
358/// Parameters for `POST /0/private/TradeBalance`.
359#[derive(Clone, Debug, Default, Serialize, Deserialize)]
360pub struct SpotTradeBalanceParams {
361    /// Denominating asset for all figures (default `"ZUSD"`).
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub asset: Option<String>,
364}
365
366#[cfg(test)]
367mod tests {
368    use rstest::rstest;
369    use serde_json;
370
371    use super::*;
372
373    #[rstest]
374    fn test_add_order_params_builder() {
375        let params = KrakenSpotAddOrderParamsBuilder::default()
376            .pair("XXBTZUSD")
377            .side(KrakenOrderSide::Buy)
378            .order_type(KrakenOrderType::Limit)
379            .volume("0.01")
380            .price("50000.0")
381            .cl_ord_id("my-order-123")
382            .broker("test-broker")
383            .build()
384            .unwrap();
385
386        assert_eq!(params.pair, Ustr::from("XXBTZUSD"));
387        assert_eq!(params.side, KrakenOrderSide::Buy);
388        assert_eq!(params.order_type, KrakenOrderType::Limit);
389        assert_eq!(params.volume, "0.01");
390        assert_eq!(params.price, Some("50000.0".to_string()));
391        assert_eq!(params.cl_ord_id, Some("my-order-123".to_string()));
392        assert_eq!(params.broker, Some(Ustr::from("test-broker")));
393    }
394
395    #[rstest]
396    fn test_add_order_params_serialization() {
397        let params = KrakenSpotAddOrderParamsBuilder::default()
398            .pair("XXBTZUSD")
399            .side(KrakenOrderSide::Buy)
400            .order_type(KrakenOrderType::Market)
401            .volume("0.01")
402            .broker("broker-id")
403            .build()
404            .unwrap();
405
406        let encoded = serde_urlencoded::to_string(&params).unwrap();
407
408        assert!(encoded.contains("pair=XXBTZUSD"));
409        assert!(encoded.contains("type=buy"));
410        assert!(encoded.contains("ordertype=market"));
411        assert!(encoded.contains("volume=0.01"));
412        assert!(encoded.contains("broker=broker-id"));
413        assert!(!encoded.contains("price="));
414    }
415
416    #[rstest]
417    fn test_add_order_params_limit_requires_price() {
418        let result = KrakenSpotAddOrderParamsBuilder::default()
419            .pair("XXBTZUSD")
420            .side(KrakenOrderSide::Buy)
421            .order_type(KrakenOrderType::Limit)
422            .volume("0.01")
423            .build();
424
425        assert!(result.is_err());
426        assert!(
427            result
428                .unwrap_err()
429                .to_string()
430                .contains("price is required")
431        );
432    }
433
434    #[rstest]
435    fn test_cancel_order_params_builder() {
436        let params = KrakenSpotCancelOrderParamsBuilder::default()
437            .txid("TXID123")
438            .build()
439            .unwrap();
440
441        assert_eq!(params.txid, Some("TXID123".to_string()));
442        assert_eq!(params.cl_ord_id, None);
443    }
444
445    #[rstest]
446    fn test_cancel_order_params_serialization() {
447        let params = KrakenSpotCancelOrderParamsBuilder::default()
448            .cl_ord_id("my-order")
449            .build()
450            .unwrap();
451
452        let encoded = serde_urlencoded::to_string(&params).unwrap();
453
454        assert!(encoded.contains("cl_ord_id=my-order"));
455        assert!(!encoded.contains("txid="));
456    }
457
458    #[rstest]
459    fn test_add_order_params_trailing_stop() {
460        let params = KrakenSpotAddOrderParamsBuilder::default()
461            .pair("XXBTZUSD")
462            .side(KrakenOrderSide::Buy)
463            .order_type(KrakenOrderType::TrailingStop)
464            .volume("0.01")
465            .price("500")
466            .build()
467            .unwrap();
468
469        let encoded = serde_urlencoded::to_string(&params).unwrap();
470
471        assert!(encoded.contains("ordertype=trailing-stop"));
472        assert!(encoded.contains("price=500"));
473    }
474
475    #[rstest]
476    fn test_add_order_params_trailing_stop_limit() {
477        let params = KrakenSpotAddOrderParamsBuilder::default()
478            .pair("XXBTZUSD")
479            .side(KrakenOrderSide::Buy)
480            .order_type(KrakenOrderType::TrailingStopLimit)
481            .volume("0.01")
482            .price("500")
483            .price2("100")
484            .build()
485            .unwrap();
486
487        let encoded = serde_urlencoded::to_string(&params).unwrap();
488
489        assert!(encoded.contains("ordertype=trailing-stop-limit"));
490        assert!(encoded.contains("price=500"));
491        assert!(encoded.contains("price2=100"));
492    }
493
494    #[rstest]
495    fn test_add_order_params_with_trigger() {
496        let params = KrakenSpotAddOrderParamsBuilder::default()
497            .pair("XXBTZUSD")
498            .side(KrakenOrderSide::Buy)
499            .order_type(KrakenOrderType::StopLoss)
500            .volume("0.01")
501            .price("50000")
502            .trigger("index")
503            .build()
504            .unwrap();
505
506        let encoded = serde_urlencoded::to_string(&params).unwrap();
507
508        assert!(encoded.contains("trigger=index"));
509    }
510
511    #[rstest]
512    fn test_add_order_params_with_displayvol() {
513        let params = KrakenSpotAddOrderParamsBuilder::default()
514            .pair("XXBTZUSD")
515            .side(KrakenOrderSide::Buy)
516            .order_type(KrakenOrderType::Limit)
517            .volume("1.0")
518            .price("50000")
519            .displayvol("0.1")
520            .build()
521            .unwrap();
522
523        let encoded = serde_urlencoded::to_string(&params).unwrap();
524
525        assert!(encoded.contains("displayvol=0.1"));
526    }
527
528    #[rstest]
529    fn test_add_order_params_with_viqc_flag() {
530        let params = KrakenSpotAddOrderParamsBuilder::default()
531            .pair("XXBTZUSD")
532            .side(KrakenOrderSide::Buy)
533            .order_type(KrakenOrderType::Market)
534            .volume("100")
535            .oflags("viqc")
536            .build()
537            .unwrap();
538
539        let encoded = serde_urlencoded::to_string(&params).unwrap();
540
541        assert!(encoded.contains("oflags=viqc"));
542    }
543
544    #[rstest]
545    fn test_batch_order_params_from_preserves_leverage() {
546        let add_params = KrakenSpotAddOrderParamsBuilder::default()
547            .pair("XXBTZUSD")
548            .side(KrakenOrderSide::Buy)
549            .order_type(KrakenOrderType::Limit)
550            .volume("0.5")
551            .price("50000")
552            .leverage("3:1")
553            .build()
554            .unwrap();
555
556        let batch: KrakenSpotBatchOrderParams = add_params.into();
557
558        assert_eq!(
559            batch.leverage.as_deref(),
560            Some("3:1"),
561            "leverage must be preserved through the From conversion"
562        );
563    }
564
565    #[rstest]
566    fn test_batch_order_params_leverage_serializes_to_json() {
567        let batch = KrakenSpotBatchOrderParams {
568            side: KrakenOrderSide::Buy,
569            order_type: KrakenOrderType::Limit,
570            volume: "0.5".to_string(),
571            price: Some("50000".to_string()),
572            price2: None,
573            cl_ord_id: None,
574            oflags: None,
575            timeinforce: None,
576            expiretm: None,
577            trigger: None,
578            displayvol: None,
579            leverage: Some("3:1".to_string()),
580            reduce_only: None,
581        };
582
583        let json = serde_json::to_string(&batch).unwrap();
584        assert!(
585            json.contains(r#""leverage":"3:1""#),
586            "leverage missing from JSON: {json}"
587        );
588    }
589
590    #[rstest]
591    fn test_batch_order_params_no_leverage_omits_field() {
592        // Without leverage the field must be absent (skip_serializing_if = "Option::is_none").
593        let batch = KrakenSpotBatchOrderParams {
594            side: KrakenOrderSide::Sell,
595            order_type: KrakenOrderType::Market,
596            volume: "0.1".to_string(),
597            price: None,
598            price2: None,
599            cl_ord_id: None,
600            oflags: None,
601            timeinforce: None,
602            expiretm: None,
603            trigger: None,
604            displayvol: None,
605            leverage: None,
606            reduce_only: None,
607        };
608
609        let json = serde_json::to_string(&batch).unwrap();
610        assert!(
611            !json.contains("leverage"),
612            "leverage should be absent for cash orders: {json}"
613        );
614    }
615}