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
96impl KrakenSpotAddOrderParamsBuilder {
97    fn validate(&self) -> Result<(), String> {
98        // Validate price is present for limit-type orders
99        if let Some(
100            KrakenOrderType::Limit
101            | KrakenOrderType::StopLossLimit
102            | KrakenOrderType::TakeProfitLimit,
103        ) = self.order_type
104            && (self.price.is_none() || self.price.as_ref().unwrap().is_none())
105        {
106            return Err("price is required for limit orders".to_string());
107        }
108
109        // Validate price2 (limit price) is present for stop-loss-limit and take-profit-limit
110        if let Some(KrakenOrderType::StopLossLimit | KrakenOrderType::TakeProfitLimit) =
111            self.order_type
112            && (self.price2.is_none() || self.price2.as_ref().unwrap().is_none())
113        {
114            return Err(
115                "price2 (limit price) is required for stop-loss-limit and take-profit-limit orders"
116                    .to_string(),
117            );
118        }
119        Ok(())
120    }
121}
122
123/// A single order payload for `POST /0/private/AddOrderBatch`.
124///
125/// This mirrors `KrakenSpotAddOrderParams` without the shared top-level `pair`
126/// and broker fields.
127#[derive(Clone, Debug, Serialize, Deserialize)]
128pub struct KrakenSpotBatchOrderParams {
129    /// Order side: "buy" or "sell".
130    #[serde(rename = "type")]
131    pub side: KrakenOrderSide,
132
133    /// Order type: market, limit, stop-loss, etc.
134    #[serde(rename = "ordertype")]
135    pub order_type: KrakenOrderType,
136
137    /// Order quantity in base currency.
138    pub volume: String,
139
140    /// Limit price or trigger price, depending on order type.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub price: Option<String>,
143
144    /// Secondary limit price for supported conditional orders.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub price2: Option<String>,
147
148    /// Client order ID.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub cl_ord_id: Option<String>,
151
152    /// Order flags (comma-separated: post, fcib, fciq, nompp, viqc).
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub oflags: Option<String>,
155
156    /// Time in force: GTC, IOC, GTD.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub timeinforce: Option<String>,
159
160    /// Expiration time for GTD orders.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub expiretm: Option<String>,
163
164    /// Trigger reference for conditional orders: "last" or "index".
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub trigger: Option<String>,
167
168    /// Display volume for iceberg orders.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub displayvol: Option<String>,
171}
172
173impl From<KrakenSpotAddOrderParams> for KrakenSpotBatchOrderParams {
174    fn from(params: KrakenSpotAddOrderParams) -> Self {
175        Self {
176            side: params.side,
177            order_type: params.order_type,
178            volume: params.volume,
179            price: params.price,
180            price2: params.price2,
181            cl_ord_id: params.cl_ord_id,
182            oflags: params.oflags,
183            timeinforce: params.timeinforce,
184            expiretm: params.expiretm,
185            trigger: params.trigger,
186            displayvol: params.displayvol,
187        }
188    }
189}
190
191/// Parameters for batch adding orders via `POST /0/private/AddOrderBatch`.
192///
193/// # References
194/// - <https://docs.kraken.com/api/docs/rest-api/add-order-batch>
195#[derive(Clone, Debug, Serialize, Deserialize)]
196pub struct KrakenSpotAddOrderBatchParams {
197    /// Asset pair shared across all orders in the batch.
198    pub pair: Ustr,
199
200    /// List of orders to submit for that pair.
201    pub orders: Vec<KrakenSpotBatchOrderParams>,
202
203    /// Asset class override for tokenized assets (xStocks).
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub asset_class: Option<KrakenAssetClass>,
206}
207
208/// Parameters for cancelling an order via `POST /0/private/CancelOrder`.
209///
210/// # References
211/// - <https://docs.kraken.com/api/docs/rest-api/cancel-order>
212#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
213#[builder(setter(into, strip_option))]
214pub struct KrakenSpotCancelOrderParams {
215    /// Transaction ID (venue order ID) to cancel.
216    /// Note: The Kraken v0 API uses `txid` as the parameter name.
217    #[builder(default)]
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub txid: Option<String>,
220
221    /// Client order ID to cancel.
222    #[builder(default)]
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub cl_ord_id: Option<String>,
225}
226
227/// Parameters for batch cancelling orders via `POST /0/private/CancelOrderBatch`.
228///
229/// # References
230/// - <https://docs.kraken.com/api/docs/rest-api/cancel-order-batch>
231#[derive(Clone, Debug, Serialize, Deserialize)]
232pub struct KrakenSpotCancelOrderBatchParams {
233    /// List of transaction IDs (venue order IDs) or client order IDs to cancel.
234    /// Maximum 50 IDs.
235    pub orders: Vec<String>,
236}
237
238/// Parameters for editing an order via `POST /0/private/EditOrder`.
239///
240/// Note: Consider using `KrakenSpotAmendOrderParams` with `AmendOrder` instead,
241/// which is faster and keeps queue priority.
242///
243/// # References
244/// - <https://docs.kraken.com/api/docs/rest-api/edit-order>
245#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
246#[builder(setter(into, strip_option))]
247pub struct KrakenSpotEditOrderParams {
248    /// Asset pair (e.g., "XXBTZUSD"). Required.
249    pub pair: Ustr,
250
251    /// Transaction ID (venue order ID) of the order to edit.
252    #[builder(default)]
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub txid: Option<String>,
255
256    /// Client order ID of the order to edit. Note: Not supported by Kraken EditOrder.
257    #[builder(default)]
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub cl_ord_id: Option<String>,
260
261    /// New order quantity in base currency.
262    #[builder(default)]
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub volume: Option<String>,
265
266    /// New limit price.
267    #[builder(default)]
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub price: Option<String>,
270
271    /// New secondary price for stop-loss-limit and take-profit-limit.
272    #[builder(default)]
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub price2: Option<String>,
275}
276
277/// Parameters for amending an order via `POST /0/private/AmendOrder`.
278///
279/// This is Kraken's atomic amend endpoint which modifies order parameters
280/// in-place without cancelling the original order. Faster and keeps queue priority.
281///
282/// # References
283/// - <https://docs.kraken.com/api/docs/rest-api/amend-order>
284#[derive(Clone, Debug, Serialize, Deserialize, Builder)]
285#[builder(setter(into, strip_option))]
286pub struct KrakenSpotAmendOrderParams {
287    /// Transaction ID (venue order ID) of the order to amend.
288    #[builder(default)]
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub txid: Option<String>,
291
292    /// Client order ID of the order to amend.
293    #[builder(default)]
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub cl_ord_id: Option<String>,
296
297    /// New order quantity in base currency.
298    #[builder(default)]
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub order_qty: Option<String>,
301
302    /// New limit price.
303    #[builder(default)]
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub limit_price: Option<String>,
306
307    /// New trigger price for stop/conditional orders.
308    #[builder(default)]
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub trigger_price: Option<String>,
311}
312
313#[cfg(test)]
314mod tests {
315    use rstest::rstest;
316
317    use super::*;
318
319    #[rstest]
320    fn test_add_order_params_builder() {
321        let params = KrakenSpotAddOrderParamsBuilder::default()
322            .pair("XXBTZUSD")
323            .side(KrakenOrderSide::Buy)
324            .order_type(KrakenOrderType::Limit)
325            .volume("0.01")
326            .price("50000.0")
327            .cl_ord_id("my-order-123")
328            .broker("test-broker")
329            .build()
330            .unwrap();
331
332        assert_eq!(params.pair, Ustr::from("XXBTZUSD"));
333        assert_eq!(params.side, KrakenOrderSide::Buy);
334        assert_eq!(params.order_type, KrakenOrderType::Limit);
335        assert_eq!(params.volume, "0.01");
336        assert_eq!(params.price, Some("50000.0".to_string()));
337        assert_eq!(params.cl_ord_id, Some("my-order-123".to_string()));
338        assert_eq!(params.broker, Some(Ustr::from("test-broker")));
339    }
340
341    #[rstest]
342    fn test_add_order_params_serialization() {
343        let params = KrakenSpotAddOrderParamsBuilder::default()
344            .pair("XXBTZUSD")
345            .side(KrakenOrderSide::Buy)
346            .order_type(KrakenOrderType::Market)
347            .volume("0.01")
348            .broker("broker-id")
349            .build()
350            .unwrap();
351
352        let encoded = serde_urlencoded::to_string(&params).unwrap();
353
354        assert!(encoded.contains("pair=XXBTZUSD"));
355        assert!(encoded.contains("type=buy"));
356        assert!(encoded.contains("ordertype=market"));
357        assert!(encoded.contains("volume=0.01"));
358        assert!(encoded.contains("broker=broker-id"));
359        assert!(!encoded.contains("price="));
360    }
361
362    #[rstest]
363    fn test_add_order_params_limit_requires_price() {
364        let result = KrakenSpotAddOrderParamsBuilder::default()
365            .pair("XXBTZUSD")
366            .side(KrakenOrderSide::Buy)
367            .order_type(KrakenOrderType::Limit)
368            .volume("0.01")
369            .build();
370
371        assert!(result.is_err());
372        assert!(
373            result
374                .unwrap_err()
375                .to_string()
376                .contains("price is required")
377        );
378    }
379
380    #[rstest]
381    fn test_cancel_order_params_builder() {
382        let params = KrakenSpotCancelOrderParamsBuilder::default()
383            .txid("TXID123")
384            .build()
385            .unwrap();
386
387        assert_eq!(params.txid, Some("TXID123".to_string()));
388        assert_eq!(params.cl_ord_id, None);
389    }
390
391    #[rstest]
392    fn test_cancel_order_params_serialization() {
393        let params = KrakenSpotCancelOrderParamsBuilder::default()
394            .cl_ord_id("my-order")
395            .build()
396            .unwrap();
397
398        let encoded = serde_urlencoded::to_string(&params).unwrap();
399
400        assert!(encoded.contains("cl_ord_id=my-order"));
401        assert!(!encoded.contains("txid="));
402    }
403
404    #[rstest]
405    fn test_add_order_params_trailing_stop() {
406        let params = KrakenSpotAddOrderParamsBuilder::default()
407            .pair("XXBTZUSD")
408            .side(KrakenOrderSide::Buy)
409            .order_type(KrakenOrderType::TrailingStop)
410            .volume("0.01")
411            .price("500")
412            .build()
413            .unwrap();
414
415        let encoded = serde_urlencoded::to_string(&params).unwrap();
416
417        assert!(encoded.contains("ordertype=trailing-stop"));
418        assert!(encoded.contains("price=500"));
419    }
420
421    #[rstest]
422    fn test_add_order_params_trailing_stop_limit() {
423        let params = KrakenSpotAddOrderParamsBuilder::default()
424            .pair("XXBTZUSD")
425            .side(KrakenOrderSide::Buy)
426            .order_type(KrakenOrderType::TrailingStopLimit)
427            .volume("0.01")
428            .price("500")
429            .price2("100")
430            .build()
431            .unwrap();
432
433        let encoded = serde_urlencoded::to_string(&params).unwrap();
434
435        assert!(encoded.contains("ordertype=trailing-stop-limit"));
436        assert!(encoded.contains("price=500"));
437        assert!(encoded.contains("price2=100"));
438    }
439
440    #[rstest]
441    fn test_add_order_params_with_trigger() {
442        let params = KrakenSpotAddOrderParamsBuilder::default()
443            .pair("XXBTZUSD")
444            .side(KrakenOrderSide::Buy)
445            .order_type(KrakenOrderType::StopLoss)
446            .volume("0.01")
447            .price("50000")
448            .trigger("index")
449            .build()
450            .unwrap();
451
452        let encoded = serde_urlencoded::to_string(&params).unwrap();
453
454        assert!(encoded.contains("trigger=index"));
455    }
456
457    #[rstest]
458    fn test_add_order_params_with_displayvol() {
459        let params = KrakenSpotAddOrderParamsBuilder::default()
460            .pair("XXBTZUSD")
461            .side(KrakenOrderSide::Buy)
462            .order_type(KrakenOrderType::Limit)
463            .volume("1.0")
464            .price("50000")
465            .displayvol("0.1")
466            .build()
467            .unwrap();
468
469        let encoded = serde_urlencoded::to_string(&params).unwrap();
470
471        assert!(encoded.contains("displayvol=0.1"));
472    }
473
474    #[rstest]
475    fn test_add_order_params_with_viqc_flag() {
476        let params = KrakenSpotAddOrderParamsBuilder::default()
477            .pair("XXBTZUSD")
478            .side(KrakenOrderSide::Buy)
479            .order_type(KrakenOrderType::Market)
480            .volume("100")
481            .oflags("viqc")
482            .build()
483            .unwrap();
484
485        let encoded = serde_urlencoded::to_string(&params).unwrap();
486
487        assert!(encoded.contains("oflags=viqc"));
488    }
489}