Skip to main content

nautilus_kraken/http/spot/
models.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//! Data models for Kraken Spot HTTP API responses.
17
18use std::fmt::Debug;
19
20use indexmap::IndexMap;
21use nautilus_core::string::secret::SecretString;
22use rust_decimal::Decimal;
23use serde::{
24    Deserialize, Deserializer, Serialize,
25    de::{MapAccess, SeqAccess, Visitor},
26};
27use ustr::Ustr;
28use zeroize::{Zeroize, ZeroizeOnDrop};
29
30use crate::common::{
31    enums::{
32        KrakenAssetClass, KrakenOrderSide, KrakenOrderStatus, KrakenOrderType, KrakenPairStatus,
33        KrakenSpotTrigger, KrakenSystemStatus,
34    },
35    serialization::{decimal, decimal_pairs},
36};
37
38/// Wrapper for Kraken API responses.
39#[derive(Debug, Clone, serde::Deserialize)]
40pub struct KrakenResponse<T> {
41    pub error: Vec<String>,
42    pub result: Option<T>,
43}
44
45// Balance Models
46
47/// Response from Kraken Balance endpoint.
48/// Maps currency codes (e.g., "USDT", "ETH") to their balance amounts as strings.
49pub type BalanceResponse = IndexMap<String, String>;
50
51/// A single per-asset entry from `POST /0/private/BalanceEx`.
52///
53/// Distinct from [`BalanceResponse`], which carries only the total wallet amount: this also
54/// reports the portion Kraken holds against resting orders, which maps to the `locked` component
55/// of [`nautilus_model::types::AccountBalance`].
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct BalanceExEntry {
58    /// Total balance amount for the asset.
59    pub balance: String,
60    /// Total held amount for the asset, reserved by the venue against resting orders.
61    pub hold_trade: String,
62    /// Total credit amount, present only for accounts with a credit line.
63    #[serde(default)]
64    pub credit: Option<String>,
65    /// Used credit amount, present only for accounts with a credit line.
66    #[serde(default)]
67    pub credit_used: Option<String>,
68}
69
70/// Response from `POST /0/private/BalanceEx`.
71/// Maps currency codes (e.g., "ZUSD", "XXBT") to their total and held amounts.
72pub type BalanceExResponse = IndexMap<String, BalanceExEntry>;
73
74/// Response from `POST /0/private/TradeBalance` (margin accounts only).
75///
76/// Distinct from [`BalanceResponse`]: wallet balances give currency amounts held; this gives
77/// margin accounting metrics (equity, used margin, free margin) denominated in a single asset.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct TradeBalanceResponse {
80    pub eb: String, // equivalent balance (all currencies combined)
81    pub tb: String, // trade balance (equity currency collateral)
82    pub m: String,  // margin amount of open positions (used margin)
83    pub uv: String, // unexecuted value of partly filled orders/positions
84    pub n: String,  // unrealized net profit/loss of open positions
85    pub c: String,  // cost basis of open positions
86    pub v: String,  // current floating valuation of open positions
87    pub e: String,  // equity = eb + n
88    pub mf: String, // free margin = e - m
89    #[serde(default)]
90    pub ml: Option<String>, // margin level % (absent when no positions are open)
91}
92
93/// A single open spot margin position from `POST /0/private/OpenPositions`.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SpotOpenPosition {
96    pub ordertxid: String,
97    pub pair: String,
98    pub time: f64,
99    #[serde(rename = "type")]
100    pub side: KrakenOrderSide,
101    pub ordertype: KrakenOrderType,
102    pub cost: String,
103    pub fee: String,
104    pub vol: String,
105    pub vol_closed: String,
106    pub margin: String,
107    #[serde(default)]
108    pub posstatus: Option<String>,
109    #[serde(default)]
110    pub value: Option<String>, // present when docalcs=true
111    #[serde(default)]
112    pub net: Option<String>, // present when docalcs=true
113    #[serde(default)]
114    pub terms: Option<String>,
115    #[serde(default)]
116    pub rollovertm: Option<String>,
117    #[serde(default)]
118    pub misc: Option<String>,
119    #[serde(default)]
120    pub oflags: Option<String>,
121}
122
123/// Response from `POST /0/private/OpenPositions`: maps position ID to position data.
124///
125/// Kraken returns `[]` (empty array) when there are no open positions, and a JSON object
126/// (map) when positions exist. The custom deserializer handles both forms.
127#[derive(Debug, Clone, Default)]
128pub struct SpotOpenPositionsResponse(IndexMap<String, SpotOpenPosition>);
129
130impl std::ops::Deref for SpotOpenPositionsResponse {
131    type Target = IndexMap<String, SpotOpenPosition>;
132    fn deref(&self) -> &Self::Target {
133        &self.0
134    }
135}
136
137impl<'de> Deserialize<'de> for SpotOpenPositionsResponse {
138    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
139        struct V;
140        impl<'de> Visitor<'de> for V {
141            type Value = SpotOpenPositionsResponse;
142            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
143                write!(f, "a map of open positions or an empty array")
144            }
145            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
146                let mut out = IndexMap::new();
147                while let Some((k, v)) = map.next_entry::<String, SpotOpenPosition>()? {
148                    out.insert(k, v);
149                }
150                Ok(SpotOpenPositionsResponse(out))
151            }
152            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
153                if seq.next_element::<serde::de::IgnoredAny>()?.is_some() {
154                    return Err(serde::de::Error::custom(
155                        "OpenPositions: expected empty array or object map, received non-empty array",
156                    ));
157                }
158                Ok(SpotOpenPositionsResponse(IndexMap::new()))
159            }
160        }
161        deserializer.deserialize_any(V)
162    }
163}
164
165// Asset Pairs (Instruments) Models
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct AssetPairInfo {
169    pub altname: Ustr,
170    pub wsname: Option<Ustr>,
171    pub aclass_base: KrakenAssetClass,
172    pub base: Ustr,
173    pub aclass_quote: KrakenAssetClass,
174    pub quote: Ustr,
175    pub cost_decimals: u8,
176    pub pair_decimals: u8,
177    pub lot_decimals: u8,
178    pub lot_multiplier: i32,
179    #[serde(default)]
180    pub leverage_buy: Vec<i32>,
181    #[serde(default)]
182    pub leverage_sell: Vec<i32>,
183    #[serde(default, with = "decimal_pairs")]
184    pub fees: Vec<(i32, Decimal)>,
185    #[serde(default, with = "decimal_pairs")]
186    pub fees_maker: Vec<(i32, Decimal)>,
187    pub fee_volume_currency: Option<Ustr>,
188    pub margin_call: Option<i32>,
189    pub margin_stop: Option<i32>,
190    pub ordermin: Option<String>,
191    pub costmin: Option<String>,
192    pub tick_size: Option<String>,
193    pub status: Option<KrakenPairStatus>,
194    #[serde(default)]
195    pub long_position_limit: Option<i64>,
196    #[serde(default)]
197    pub short_position_limit: Option<i64>,
198}
199
200pub type AssetPairsResponse = IndexMap<String, AssetPairInfo>;
201
202#[derive(Debug, Clone, Deserialize)]
203pub(crate) struct SpotTradeVolumeFee {
204    #[serde(with = "decimal")]
205    pub fee: Decimal,
206}
207
208#[derive(Debug, Clone, Deserialize)]
209pub(crate) struct SpotTradeVolumeResponse {
210    pub fees: IndexMap<String, SpotTradeVolumeFee>,
211    #[serde(default)]
212    pub fees_maker: IndexMap<String, SpotTradeVolumeFee>,
213}
214
215// Ticker Models
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct TickerInfo {
219    #[serde(rename = "a")]
220    pub ask: Vec<String>,
221    #[serde(rename = "b")]
222    pub bid: Vec<String>,
223    #[serde(rename = "c")]
224    pub last: Vec<String>,
225    #[serde(rename = "v")]
226    pub volume: Vec<String>,
227    #[serde(rename = "p")]
228    pub vwap: Vec<String>,
229    #[serde(rename = "t")]
230    pub trades: Vec<i64>,
231    #[serde(rename = "l")]
232    pub low: Vec<String>,
233    #[serde(rename = "h")]
234    pub high: Vec<String>,
235    #[serde(rename = "o")]
236    pub open: String,
237}
238
239pub type TickerResponse = IndexMap<String, TickerInfo>;
240
241// OHLC (Candlestick) Models
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct OhlcData {
245    pub time: i64,
246    pub open: String,
247    pub high: String,
248    pub low: String,
249    pub close: String,
250    pub vwap: String,
251    pub volume: String,
252    pub count: i64,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct OhlcResponse {
257    pub last: i64,
258    #[serde(flatten)]
259    pub data: IndexMap<String, Vec<Vec<serde_json::Value>>>,
260}
261
262// Trades Models
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct TradeData {
266    pub price: String,
267    pub volume: String,
268    pub time: f64,
269    pub side: KrakenOrderSide,
270    pub order_type: KrakenOrderType,
271    pub misc: String,
272    #[serde(default)]
273    pub trade_id: Option<i64>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TradesResponse {
278    pub last: String,
279    #[serde(flatten)]
280    pub data: IndexMap<String, Vec<Vec<serde_json::Value>>>,
281}
282
283// Order Book Models
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct OrderBookLevel {
287    pub price: String,
288    pub volume: String,
289    pub timestamp: i64,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct OrderBookData {
294    pub asks: Vec<Vec<serde_json::Value>>,
295    pub bids: Vec<Vec<serde_json::Value>>,
296}
297
298pub type OrderBookResponse = IndexMap<String, OrderBookData>;
299
300// System Status Models
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct SystemStatus {
304    pub status: KrakenSystemStatus,
305    pub timestamp: String,
306}
307
308// Server Time Models
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct ServerTime {
312    pub unixtime: i64,
313    pub rfc1123: String,
314}
315
316// WebSocket Token Models
317
318#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
319pub struct WebSocketToken {
320    pub token: SecretString,
321    pub expires: i32,
322}
323
324impl WebSocketToken {
325    /// Consumes the response and returns the WebSocket token.
326    #[must_use]
327    pub fn into_token(mut self) -> SecretString {
328        std::mem::take(&mut self.token)
329    }
330}
331
332// Spot Private Trading Models
333
334/// Order description from QueryOrders response (full details, required fields).
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct OrderDescription {
337    pub pair: String,
338    #[serde(rename = "type")]
339    pub order_side: KrakenOrderSide,
340    pub ordertype: KrakenOrderType,
341    pub price: String,
342    pub price2: String,
343    pub leverage: String,
344    pub order: String,
345    pub close: Option<String>,
346}
347
348/// Order description from AddOrder response (simpler, with optional fields).
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct AddOrderDescription {
351    #[serde(default)]
352    pub order: Option<String>,
353    #[serde(default)]
354    pub close: Option<String>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct SpotOrder {
359    pub refid: Option<String>,
360    pub userref: Option<i64>,
361    pub status: KrakenOrderStatus,
362    pub opentm: f64,
363    pub starttm: Option<f64>,
364    pub expiretm: Option<f64>,
365    pub descr: OrderDescription,
366    pub vol: String,
367    pub vol_exec: String,
368    pub cost: String,
369    pub fee: String,
370    pub price: String,
371    pub stopprice: Option<String>,
372    pub limitprice: Option<String>,
373    pub trigger: Option<KrakenSpotTrigger>,
374    pub misc: String,
375    pub oflags: String,
376    #[serde(default)]
377    pub trades: Option<Vec<String>>,
378    #[serde(default)]
379    pub closetm: Option<f64>,
380    #[serde(default)]
381    pub reason: Option<String>,
382    #[serde(default)]
383    pub ratecount: Option<i32>,
384    #[serde(default)]
385    pub cl_ord_id: Option<String>,
386    #[serde(default)]
387    pub amended: Option<bool>,
388    /// Average fill price (if returned by the API)
389    #[serde(default)]
390    pub avg_price: Option<String>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct SpotOpenOrdersResult {
395    pub open: IndexMap<String, SpotOrder>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct SpotClosedOrdersResult {
400    pub closed: IndexMap<String, SpotOrder>,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct SpotTrade {
405    pub ordertxid: String,
406    pub postxid: String,
407    pub pair: String,
408    pub time: f64,
409    #[serde(rename = "type")]
410    pub trade_type: KrakenOrderSide,
411    pub ordertype: KrakenOrderType,
412    pub price: String,
413    pub cost: String,
414    pub fee: String,
415    pub vol: String,
416    pub margin: String,
417    pub leverage: Option<String>,
418    pub misc: String,
419    #[serde(default)]
420    pub trade_id: Option<i64>,
421    #[serde(default)]
422    pub maker: Option<bool>,
423    #[serde(default)]
424    pub ledgers: Option<Vec<String>>,
425    #[serde(default)]
426    pub posstatus: Option<String>,
427    #[serde(default)]
428    pub cprice: Option<String>,
429    #[serde(default)]
430    pub ccost: Option<String>,
431    #[serde(default)]
432    pub cfee: Option<String>,
433    #[serde(default)]
434    pub cvol: Option<String>,
435    #[serde(default)]
436    pub cmargin: Option<String>,
437    #[serde(default)]
438    pub net: Option<String>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct SpotTradesHistoryResult {
443    pub trades: IndexMap<String, SpotTrade>,
444    pub count: i32,
445}
446
447// Spot Order Execution Models
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct SpotAddOrderResponse {
451    pub descr: Option<AddOrderDescription>,
452    #[serde(default)]
453    pub txid: Vec<String>,
454    #[serde(default)]
455    pub cl_ord_id: Option<String>,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct SpotBatchOrderResponse {
460    #[serde(default)]
461    pub descr: Option<AddOrderDescription>,
462    #[serde(default)]
463    pub error: Option<String>,
464    #[serde(default)]
465    pub txid: Option<String>,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct SpotAddOrderBatchResponse {
470    #[serde(default)]
471    pub orders: Vec<SpotBatchOrderResponse>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct SpotCancelOrderResponse {
476    pub count: i32,
477    #[serde(default)]
478    pub pending: Option<bool>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct SpotCancelOrderBatchResponse {
483    pub count: i32,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct SpotEditOrderResponse {
488    pub descr: Option<AddOrderDescription>,
489    pub txid: Option<String>,
490    #[serde(default)]
491    pub originaltxid: Option<String>,
492    #[serde(default)]
493    pub volume: Option<String>,
494    #[serde(default)]
495    pub price: Option<String>,
496    #[serde(default)]
497    pub price2: Option<String>,
498    #[serde(default)]
499    pub orders_cancelled: Option<i32>,
500}
501
502/// Response from `POST /0/private/AmendOrder`.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct SpotAmendOrderResponse {
505    /// The amend transaction ID.
506    pub amend_id: String,
507}
508
509#[cfg(test)]
510mod tests {
511    use rstest::rstest;
512
513    use super::*;
514
515    fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
516
517    fn load_test_data(filename: &str) -> String {
518        let path = format!("test_data/{filename}");
519        std::fs::read_to_string(&path)
520            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
521    }
522
523    #[rstest]
524    fn test_websocket_token_zeroizes_on_drop() {
525        assert_zeroize_on_drop::<WebSocketToken>();
526
527        let token = WebSocketToken {
528            token: SecretString::from("websocket-token-value"),
529            expires: 900,
530        };
531        let formatted = format!("{token:?}");
532
533        assert_eq!(
534            formatted,
535            "WebSocketToken { token: <redacted>, expires: 900 }"
536        );
537        assert!(!formatted.contains(token.token.expose_secret()));
538    }
539
540    #[rstest]
541    fn test_parse_server_time() {
542        let data = load_test_data("http_server_time.json");
543        let response: KrakenResponse<ServerTime> =
544            serde_json::from_str(&data).expect("Failed to parse server time");
545
546        assert!(response.error.is_empty());
547        let result = response.result.expect("Missing result");
548        assert!(result.unixtime > 0);
549        assert!(!result.rfc1123.is_empty());
550    }
551
552    #[rstest]
553    fn test_parse_system_status() {
554        let data = load_test_data("http_system_status.json");
555        let response: KrakenResponse<SystemStatus> =
556            serde_json::from_str(&data).expect("Failed to parse system status");
557
558        assert!(response.error.is_empty());
559        let result = response.result.expect("Missing result");
560        assert!(!result.timestamp.is_empty());
561    }
562
563    #[rstest]
564    fn test_parse_asset_pairs() {
565        let data = load_test_data("http_asset_pairs.json");
566        let response: KrakenResponse<AssetPairsResponse> =
567            serde_json::from_str(&data).expect("Failed to parse asset pairs");
568
569        assert!(response.error.is_empty());
570        let result = response.result.expect("Missing result");
571        assert!(!result.is_empty());
572
573        let pair = result.get("XBTUSDT").expect("XBTUSDT pair not found");
574        assert_eq!(pair.altname, "XBTUSDT");
575        assert_eq!(pair.base, "XXBT");
576        assert_eq!(pair.quote, "USDT");
577        assert!(pair.wsname.is_some());
578    }
579
580    #[rstest]
581    fn test_parse_ticker() {
582        let data = load_test_data("http_ticker.json");
583        let response: KrakenResponse<TickerResponse> =
584            serde_json::from_str(&data).expect("Failed to parse ticker");
585
586        assert!(response.error.is_empty());
587        let result = response.result.expect("Missing result");
588        assert!(!result.is_empty());
589
590        let ticker = result.get("XBTUSDT").expect("XBTUSDT ticker not found");
591        assert_eq!(ticker.ask.len(), 3);
592        assert_eq!(ticker.bid.len(), 3);
593        assert_eq!(ticker.last.len(), 2);
594    }
595
596    #[rstest]
597    fn test_parse_ohlc() {
598        let data = load_test_data("http_ohlc.json");
599        let response: KrakenResponse<serde_json::Value> =
600            serde_json::from_str(&data).expect("Failed to parse OHLC");
601
602        assert!(response.error.is_empty());
603        assert!(response.result.is_some());
604    }
605
606    #[rstest]
607    fn test_parse_order_book() {
608        let data = load_test_data("http_order_book.json");
609        let response: KrakenResponse<OrderBookResponse> =
610            serde_json::from_str(&data).expect("Failed to parse order book");
611
612        assert!(response.error.is_empty());
613        let result = response.result.expect("Missing result");
614        assert!(!result.is_empty());
615
616        let book = result.get("XBTUSDT").expect("XBTUSDT order book not found");
617        assert!(!book.asks.is_empty());
618        assert!(!book.bids.is_empty());
619    }
620
621    #[rstest]
622    fn test_parse_trades() {
623        let data = load_test_data("http_trades.json");
624        let response: KrakenResponse<TradesResponse> =
625            serde_json::from_str(&data).expect("Failed to parse trades");
626
627        assert!(response.error.is_empty());
628        let result = response.result.expect("Missing result");
629        assert!(!result.data.is_empty());
630    }
631
632    #[rstest]
633    fn test_open_positions_empty_array() {
634        let result: SpotOpenPositionsResponse = serde_json::from_str("[]").unwrap();
635        assert!(result.is_empty());
636    }
637
638    #[rstest]
639    fn test_open_positions_empty_object() {
640        let result: SpotOpenPositionsResponse = serde_json::from_str("{}").unwrap();
641        assert!(result.is_empty());
642    }
643
644    #[rstest]
645    fn test_open_positions_non_empty_array_errors() {
646        let err =
647            serde_json::from_str::<SpotOpenPositionsResponse>(r#"[{"posid": "123"}]"#).unwrap_err();
648        assert!(
649            err.to_string()
650                .contains("OpenPositions: expected empty array or object map"),
651            "unexpected error: {err}"
652        );
653    }
654}