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    /// List of transaction IDs (venue order IDs) or client order IDs to cancel.
258    /// Maximum 50 IDs.
259    pub orders: Vec<String>,
260}
261
262/// Parameters for editing an order via `POST /0/private/EditOrder`.
263///
264/// Note: Consider using `KrakenSpotAmendOrderParams` with `AmendOrder` instead,
265/// which is faster and keeps queue priority.
266///
267/// # References
268/// - <https://docs.kraken.com/api/docs/rest-api/edit-order>
269#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
270#[builder(setter(into, strip_option))]
271pub struct KrakenSpotEditOrderParams {
272    /// Asset pair (e.g., "XXBTZUSD"). Required.
273    pub pair: Ustr,
274
275    /// Transaction ID (venue order ID) of the order to edit.
276    #[builder(default)]
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub txid: Option<String>,
279
280    /// Client order ID of the order to edit. Note: Not supported by Kraken EditOrder.
281    #[builder(default)]
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub cl_ord_id: Option<String>,
284
285    /// New order quantity in base currency.
286    #[builder(default)]
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub volume: Option<String>,
289
290    /// New limit price.
291    #[builder(default)]
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub price: Option<String>,
294
295    /// New secondary price for stop-loss-limit and take-profit-limit.
296    #[builder(default)]
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub price2: Option<String>,
299}
300
301/// Parameters for amending an order via `POST /0/private/AmendOrder`.
302///
303/// This is Kraken's atomic amend endpoint which modifies order parameters
304/// in-place without cancelling the original order. Faster and keeps queue priority.
305///
306/// # References
307/// - <https://docs.kraken.com/api/docs/rest-api/amend-order>
308#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
309#[builder(setter(into, strip_option))]
310pub struct KrakenSpotAmendOrderParams {
311    /// Transaction ID (venue order ID) of the order to amend.
312    #[builder(default)]
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub txid: Option<String>,
315
316    /// Client order ID of the order to amend.
317    #[builder(default)]
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub cl_ord_id: Option<String>,
320
321    /// New order quantity in base currency.
322    #[builder(default)]
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub order_qty: Option<String>,
325
326    /// New limit price.
327    #[builder(default)]
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub limit_price: Option<String>,
330
331    /// New trigger price for stop/conditional orders.
332    #[builder(default)]
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub trigger_price: Option<String>,
335}
336
337/// Parameters for `POST /0/private/OpenPositions`.
338#[derive(Clone, Debug, Default, Serialize, Deserialize)]
339pub struct SpotOpenPositionsParams {
340    /// Filter to a specific position by transaction ID.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub txid: Option<String>,
343
344    /// If `true`, include profit/loss calculations (`value` and `net` fields) in response.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub docalcs: Option<bool>,
347
348    /// `"market"` to consolidate positions by market.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub consolidation: Option<String>,
351}
352
353/// Parameters for `POST /0/private/TradeBalance`.
354#[derive(Clone, Debug, Default, Serialize, Deserialize)]
355pub struct SpotTradeBalanceParams {
356    /// Denominating asset for all figures (default `"ZUSD"`).
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub asset: Option<String>,
359}
360
361#[cfg(test)]
362mod tests {
363    use rstest::rstest;
364    use serde_json;
365
366    use super::*;
367
368    #[rstest]
369    fn test_add_order_params_builder() {
370        let params = KrakenSpotAddOrderParamsBuilder::default()
371            .pair("XXBTZUSD")
372            .side(KrakenOrderSide::Buy)
373            .order_type(KrakenOrderType::Limit)
374            .volume("0.01")
375            .price("50000.0")
376            .cl_ord_id("my-order-123")
377            .broker("test-broker")
378            .build()
379            .unwrap();
380
381        assert_eq!(params.pair, Ustr::from("XXBTZUSD"));
382        assert_eq!(params.side, KrakenOrderSide::Buy);
383        assert_eq!(params.order_type, KrakenOrderType::Limit);
384        assert_eq!(params.volume, "0.01");
385        assert_eq!(params.price, Some("50000.0".to_string()));
386        assert_eq!(params.cl_ord_id, Some("my-order-123".to_string()));
387        assert_eq!(params.broker, Some(Ustr::from("test-broker")));
388    }
389
390    #[rstest]
391    fn test_add_order_params_serialization() {
392        let params = KrakenSpotAddOrderParamsBuilder::default()
393            .pair("XXBTZUSD")
394            .side(KrakenOrderSide::Buy)
395            .order_type(KrakenOrderType::Market)
396            .volume("0.01")
397            .broker("broker-id")
398            .build()
399            .unwrap();
400
401        let encoded = serde_urlencoded::to_string(&params).unwrap();
402
403        assert!(encoded.contains("pair=XXBTZUSD"));
404        assert!(encoded.contains("type=buy"));
405        assert!(encoded.contains("ordertype=market"));
406        assert!(encoded.contains("volume=0.01"));
407        assert!(encoded.contains("broker=broker-id"));
408        assert!(!encoded.contains("price="));
409    }
410
411    #[rstest]
412    fn test_add_order_params_limit_requires_price() {
413        let result = KrakenSpotAddOrderParamsBuilder::default()
414            .pair("XXBTZUSD")
415            .side(KrakenOrderSide::Buy)
416            .order_type(KrakenOrderType::Limit)
417            .volume("0.01")
418            .build();
419
420        assert!(result.is_err());
421        assert!(
422            result
423                .unwrap_err()
424                .to_string()
425                .contains("price is required")
426        );
427    }
428
429    #[rstest]
430    fn test_cancel_order_params_builder() {
431        let params = KrakenSpotCancelOrderParamsBuilder::default()
432            .txid("TXID123")
433            .build()
434            .unwrap();
435
436        assert_eq!(params.txid, Some("TXID123".to_string()));
437        assert_eq!(params.cl_ord_id, None);
438    }
439
440    #[rstest]
441    fn test_cancel_order_params_serialization() {
442        let params = KrakenSpotCancelOrderParamsBuilder::default()
443            .cl_ord_id("my-order")
444            .build()
445            .unwrap();
446
447        let encoded = serde_urlencoded::to_string(&params).unwrap();
448
449        assert!(encoded.contains("cl_ord_id=my-order"));
450        assert!(!encoded.contains("txid="));
451    }
452
453    #[rstest]
454    fn test_add_order_params_trailing_stop() {
455        let params = KrakenSpotAddOrderParamsBuilder::default()
456            .pair("XXBTZUSD")
457            .side(KrakenOrderSide::Buy)
458            .order_type(KrakenOrderType::TrailingStop)
459            .volume("0.01")
460            .price("500")
461            .build()
462            .unwrap();
463
464        let encoded = serde_urlencoded::to_string(&params).unwrap();
465
466        assert!(encoded.contains("ordertype=trailing-stop"));
467        assert!(encoded.contains("price=500"));
468    }
469
470    #[rstest]
471    fn test_add_order_params_trailing_stop_limit() {
472        let params = KrakenSpotAddOrderParamsBuilder::default()
473            .pair("XXBTZUSD")
474            .side(KrakenOrderSide::Buy)
475            .order_type(KrakenOrderType::TrailingStopLimit)
476            .volume("0.01")
477            .price("500")
478            .price2("100")
479            .build()
480            .unwrap();
481
482        let encoded = serde_urlencoded::to_string(&params).unwrap();
483
484        assert!(encoded.contains("ordertype=trailing-stop-limit"));
485        assert!(encoded.contains("price=500"));
486        assert!(encoded.contains("price2=100"));
487    }
488
489    #[rstest]
490    fn test_add_order_params_with_trigger() {
491        let params = KrakenSpotAddOrderParamsBuilder::default()
492            .pair("XXBTZUSD")
493            .side(KrakenOrderSide::Buy)
494            .order_type(KrakenOrderType::StopLoss)
495            .volume("0.01")
496            .price("50000")
497            .trigger("index")
498            .build()
499            .unwrap();
500
501        let encoded = serde_urlencoded::to_string(&params).unwrap();
502
503        assert!(encoded.contains("trigger=index"));
504    }
505
506    #[rstest]
507    fn test_add_order_params_with_displayvol() {
508        let params = KrakenSpotAddOrderParamsBuilder::default()
509            .pair("XXBTZUSD")
510            .side(KrakenOrderSide::Buy)
511            .order_type(KrakenOrderType::Limit)
512            .volume("1.0")
513            .price("50000")
514            .displayvol("0.1")
515            .build()
516            .unwrap();
517
518        let encoded = serde_urlencoded::to_string(&params).unwrap();
519
520        assert!(encoded.contains("displayvol=0.1"));
521    }
522
523    #[rstest]
524    fn test_add_order_params_with_viqc_flag() {
525        let params = KrakenSpotAddOrderParamsBuilder::default()
526            .pair("XXBTZUSD")
527            .side(KrakenOrderSide::Buy)
528            .order_type(KrakenOrderType::Market)
529            .volume("100")
530            .oflags("viqc")
531            .build()
532            .unwrap();
533
534        let encoded = serde_urlencoded::to_string(&params).unwrap();
535
536        assert!(encoded.contains("oflags=viqc"));
537    }
538
539    #[rstest]
540    fn test_batch_order_params_from_preserves_leverage() {
541        let add_params = KrakenSpotAddOrderParamsBuilder::default()
542            .pair("XXBTZUSD")
543            .side(KrakenOrderSide::Buy)
544            .order_type(KrakenOrderType::Limit)
545            .volume("0.5")
546            .price("50000")
547            .leverage("3:1")
548            .build()
549            .unwrap();
550
551        let batch: KrakenSpotBatchOrderParams = add_params.into();
552
553        assert_eq!(
554            batch.leverage.as_deref(),
555            Some("3:1"),
556            "leverage must be preserved through the From conversion"
557        );
558    }
559
560    #[rstest]
561    fn test_batch_order_params_leverage_serializes_to_json() {
562        let batch = KrakenSpotBatchOrderParams {
563            side: KrakenOrderSide::Buy,
564            order_type: KrakenOrderType::Limit,
565            volume: "0.5".to_string(),
566            price: Some("50000".to_string()),
567            price2: None,
568            cl_ord_id: None,
569            oflags: None,
570            timeinforce: None,
571            expiretm: None,
572            trigger: None,
573            displayvol: None,
574            leverage: Some("3:1".to_string()),
575            reduce_only: None,
576        };
577
578        let json = serde_json::to_string(&batch).unwrap();
579        assert!(
580            json.contains(r#""leverage":"3:1""#),
581            "leverage missing from JSON: {json}"
582        );
583    }
584
585    #[rstest]
586    fn test_batch_order_params_no_leverage_omits_field() {
587        // Without leverage the field must be absent (skip_serializing_if = "Option::is_none").
588        let batch = KrakenSpotBatchOrderParams {
589            side: KrakenOrderSide::Sell,
590            order_type: KrakenOrderType::Market,
591            volume: "0.1".to_string(),
592            price: None,
593            price2: None,
594            cl_ord_id: None,
595            oflags: None,
596            timeinforce: None,
597            expiretm: None,
598            trigger: None,
599            displayvol: None,
600            leverage: None,
601            reduce_only: None,
602        };
603
604        let json = serde_json::to_string(&batch).unwrap();
605        assert!(
606            !json.contains("leverage"),
607            "leverage should be absent for cash orders: {json}"
608        );
609    }
610}