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)]
203#[allow(
204    dead_code,
205    reason = "TradeVolume response retained for account fee-rate follow-up"
206)]
207pub(crate) struct SpotTradeVolumeFee {
208    #[serde(with = "decimal")]
209    pub fee: Decimal,
210}
211
212#[derive(Debug, Clone, Deserialize)]
213#[allow(
214    dead_code,
215    reason = "TradeVolume response retained for account fee-rate follow-up"
216)]
217pub(crate) struct SpotTradeVolumeResponse {
218    pub fees: IndexMap<String, SpotTradeVolumeFee>,
219    #[serde(default)]
220    pub fees_maker: IndexMap<String, SpotTradeVolumeFee>,
221}
222
223// Ticker Models
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct TickerInfo {
227    #[serde(rename = "a")]
228    pub ask: Vec<String>,
229    #[serde(rename = "b")]
230    pub bid: Vec<String>,
231    #[serde(rename = "c")]
232    pub last: Vec<String>,
233    #[serde(rename = "v")]
234    pub volume: Vec<String>,
235    #[serde(rename = "p")]
236    pub vwap: Vec<String>,
237    #[serde(rename = "t")]
238    pub trades: Vec<i64>,
239    #[serde(rename = "l")]
240    pub low: Vec<String>,
241    #[serde(rename = "h")]
242    pub high: Vec<String>,
243    #[serde(rename = "o")]
244    pub open: String,
245}
246
247pub type TickerResponse = IndexMap<String, TickerInfo>;
248
249// OHLC (Candlestick) Models
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct OhlcData {
253    pub time: i64,
254    pub open: String,
255    pub high: String,
256    pub low: String,
257    pub close: String,
258    pub vwap: String,
259    pub volume: String,
260    pub count: i64,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct OhlcResponse {
265    pub last: i64,
266    #[serde(flatten)]
267    pub data: IndexMap<String, Vec<Vec<serde_json::Value>>>,
268}
269
270// Trades Models
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct TradeData {
274    pub price: String,
275    pub volume: String,
276    pub time: f64,
277    pub side: KrakenOrderSide,
278    pub order_type: KrakenOrderType,
279    pub misc: String,
280    #[serde(default)]
281    pub trade_id: Option<i64>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct TradesResponse {
286    pub last: String,
287    #[serde(flatten)]
288    pub data: IndexMap<String, Vec<Vec<serde_json::Value>>>,
289}
290
291// Order Book Models
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct OrderBookLevel {
295    pub price: String,
296    pub volume: String,
297    pub timestamp: i64,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct OrderBookData {
302    pub asks: Vec<Vec<serde_json::Value>>,
303    pub bids: Vec<Vec<serde_json::Value>>,
304}
305
306pub type OrderBookResponse = IndexMap<String, OrderBookData>;
307
308// System Status Models
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SystemStatus {
312    pub status: KrakenSystemStatus,
313    pub timestamp: String,
314}
315
316// Server Time Models
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ServerTime {
320    pub unixtime: i64,
321    pub rfc1123: String,
322}
323
324// WebSocket Token Models
325
326#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
327pub struct WebSocketToken {
328    pub token: SecretString,
329    pub expires: i32,
330}
331
332impl WebSocketToken {
333    /// Consumes the response and returns the WebSocket token.
334    #[must_use]
335    pub fn into_token(mut self) -> SecretString {
336        std::mem::take(&mut self.token)
337    }
338}
339
340// Spot Private Trading Models
341
342/// Order description from QueryOrders response (full details, required fields).
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct OrderDescription {
345    pub pair: String,
346    #[serde(rename = "type")]
347    pub order_side: KrakenOrderSide,
348    pub ordertype: KrakenOrderType,
349    pub price: String,
350    pub price2: String,
351    pub leverage: String,
352    pub order: String,
353    pub close: Option<String>,
354}
355
356/// Order description from AddOrder response (simpler, with optional fields).
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct AddOrderDescription {
359    #[serde(default)]
360    pub order: Option<String>,
361    #[serde(default)]
362    pub close: Option<String>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct SpotOrder {
367    pub refid: Option<String>,
368    pub userref: Option<i64>,
369    pub status: KrakenOrderStatus,
370    pub opentm: f64,
371    pub starttm: Option<f64>,
372    pub expiretm: Option<f64>,
373    pub descr: OrderDescription,
374    pub vol: String,
375    pub vol_exec: String,
376    pub cost: String,
377    pub fee: String,
378    pub price: String,
379    pub stopprice: Option<String>,
380    pub limitprice: Option<String>,
381    pub trigger: Option<KrakenSpotTrigger>,
382    pub misc: String,
383    pub oflags: String,
384    #[serde(default)]
385    pub trades: Option<Vec<String>>,
386    #[serde(default)]
387    pub closetm: Option<f64>,
388    #[serde(default)]
389    pub reason: Option<String>,
390    #[serde(default)]
391    pub ratecount: Option<i32>,
392    #[serde(default)]
393    pub cl_ord_id: Option<String>,
394    #[serde(default)]
395    pub amended: Option<bool>,
396    /// Average fill price (if returned by the API)
397    #[serde(default)]
398    pub avg_price: Option<String>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct SpotOpenOrdersResult {
403    pub open: IndexMap<String, SpotOrder>,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct SpotClosedOrdersResult {
408    pub closed: IndexMap<String, SpotOrder>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct SpotTrade {
413    pub ordertxid: String,
414    pub postxid: String,
415    pub pair: String,
416    pub time: f64,
417    #[serde(rename = "type")]
418    pub trade_type: KrakenOrderSide,
419    pub ordertype: KrakenOrderType,
420    pub price: String,
421    pub cost: String,
422    pub fee: String,
423    pub vol: String,
424    pub margin: String,
425    pub leverage: Option<String>,
426    pub misc: String,
427    #[serde(default)]
428    pub trade_id: Option<i64>,
429    #[serde(default)]
430    pub maker: Option<bool>,
431    #[serde(default)]
432    pub ledgers: Option<Vec<String>>,
433    #[serde(default)]
434    pub posstatus: Option<String>,
435    #[serde(default)]
436    pub cprice: Option<String>,
437    #[serde(default)]
438    pub ccost: Option<String>,
439    #[serde(default)]
440    pub cfee: Option<String>,
441    #[serde(default)]
442    pub cvol: Option<String>,
443    #[serde(default)]
444    pub cmargin: Option<String>,
445    #[serde(default)]
446    pub net: Option<String>,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct SpotTradesHistoryResult {
451    pub trades: IndexMap<String, SpotTrade>,
452    pub count: i32,
453}
454
455// Spot Order Execution Models
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct SpotAddOrderResponse {
459    pub descr: Option<AddOrderDescription>,
460    #[serde(default)]
461    pub txid: Vec<String>,
462    #[serde(default)]
463    pub cl_ord_id: Option<String>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct SpotBatchOrderResponse {
468    #[serde(default)]
469    pub descr: Option<AddOrderDescription>,
470    #[serde(default)]
471    pub error: Option<String>,
472    #[serde(default)]
473    pub txid: Option<String>,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct SpotAddOrderBatchResponse {
478    #[serde(default)]
479    pub orders: Vec<SpotBatchOrderResponse>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct SpotCancelOrderResponse {
484    pub count: i32,
485    #[serde(default)]
486    pub pending: Option<bool>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct SpotCancelOrderBatchResponse {
491    pub count: i32,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct SpotEditOrderResponse {
496    pub descr: Option<AddOrderDescription>,
497    pub txid: Option<String>,
498    #[serde(default)]
499    pub originaltxid: Option<String>,
500    #[serde(default)]
501    pub volume: Option<String>,
502    #[serde(default)]
503    pub price: Option<String>,
504    #[serde(default)]
505    pub price2: Option<String>,
506    #[serde(default)]
507    pub orders_cancelled: Option<i32>,
508}
509
510/// Response from `POST /0/private/AmendOrder`.
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct SpotAmendOrderResponse {
513    /// The amend transaction ID.
514    pub amend_id: String,
515}
516
517#[cfg(test)]
518mod tests {
519    use rstest::rstest;
520
521    use super::*;
522
523    fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
524
525    fn load_test_data(filename: &str) -> String {
526        let path = format!("test_data/{filename}");
527        std::fs::read_to_string(&path)
528            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
529    }
530
531    #[rstest]
532    fn test_websocket_token_zeroizes_on_drop() {
533        assert_zeroize_on_drop::<WebSocketToken>();
534
535        let token = WebSocketToken {
536            token: SecretString::from("websocket-token-value"),
537            expires: 900,
538        };
539        let formatted = format!("{token:?}");
540
541        assert_eq!(
542            formatted,
543            "WebSocketToken { token: <redacted>, expires: 900 }"
544        );
545        assert!(!formatted.contains(token.token.expose_secret()));
546    }
547
548    #[rstest]
549    fn test_parse_server_time() {
550        let data = load_test_data("http_server_time.json");
551        let response: KrakenResponse<ServerTime> =
552            serde_json::from_str(&data).expect("Failed to parse server time");
553
554        assert!(response.error.is_empty());
555        let result = response.result.expect("Missing result");
556        assert!(result.unixtime > 0);
557        assert!(!result.rfc1123.is_empty());
558    }
559
560    #[rstest]
561    fn test_parse_system_status() {
562        let data = load_test_data("http_system_status.json");
563        let response: KrakenResponse<SystemStatus> =
564            serde_json::from_str(&data).expect("Failed to parse system status");
565
566        assert!(response.error.is_empty());
567        let result = response.result.expect("Missing result");
568        assert!(!result.timestamp.is_empty());
569    }
570
571    #[rstest]
572    fn test_parse_asset_pairs() {
573        let data = load_test_data("http_asset_pairs.json");
574        let response: KrakenResponse<AssetPairsResponse> =
575            serde_json::from_str(&data).expect("Failed to parse asset pairs");
576
577        assert!(response.error.is_empty());
578        let result = response.result.expect("Missing result");
579        assert!(!result.is_empty());
580
581        let pair = result.get("XBTUSDT").expect("XBTUSDT pair not found");
582        assert_eq!(pair.altname, "XBTUSDT");
583        assert_eq!(pair.base, "XXBT");
584        assert_eq!(pair.quote, "USDT");
585        assert!(pair.wsname.is_some());
586    }
587
588    #[rstest]
589    fn test_parse_ticker() {
590        let data = load_test_data("http_ticker.json");
591        let response: KrakenResponse<TickerResponse> =
592            serde_json::from_str(&data).expect("Failed to parse ticker");
593
594        assert!(response.error.is_empty());
595        let result = response.result.expect("Missing result");
596        assert!(!result.is_empty());
597
598        let ticker = result.get("XBTUSDT").expect("XBTUSDT ticker not found");
599        assert_eq!(ticker.ask.len(), 3);
600        assert_eq!(ticker.bid.len(), 3);
601        assert_eq!(ticker.last.len(), 2);
602    }
603
604    #[rstest]
605    fn test_parse_ohlc() {
606        let data = load_test_data("http_ohlc.json");
607        let response: KrakenResponse<serde_json::Value> =
608            serde_json::from_str(&data).expect("Failed to parse OHLC");
609
610        assert!(response.error.is_empty());
611        assert!(response.result.is_some());
612    }
613
614    #[rstest]
615    fn test_parse_order_book() {
616        let data = load_test_data("http_order_book.json");
617        let response: KrakenResponse<OrderBookResponse> =
618            serde_json::from_str(&data).expect("Failed to parse order book");
619
620        assert!(response.error.is_empty());
621        let result = response.result.expect("Missing result");
622        assert!(!result.is_empty());
623
624        let book = result.get("XBTUSDT").expect("XBTUSDT order book not found");
625        assert!(!book.asks.is_empty());
626        assert!(!book.bids.is_empty());
627    }
628
629    #[rstest]
630    fn test_parse_trades() {
631        let data = load_test_data("http_trades.json");
632        let response: KrakenResponse<TradesResponse> =
633            serde_json::from_str(&data).expect("Failed to parse trades");
634
635        assert!(response.error.is_empty());
636        let result = response.result.expect("Missing result");
637        assert!(!result.data.is_empty());
638    }
639
640    #[rstest]
641    fn test_open_positions_empty_array() {
642        let result: SpotOpenPositionsResponse = serde_json::from_str("[]").unwrap();
643        assert!(result.is_empty());
644    }
645
646    #[rstest]
647    fn test_open_positions_empty_object() {
648        let result: SpotOpenPositionsResponse = serde_json::from_str("{}").unwrap();
649        assert!(result.is_empty());
650    }
651
652    #[rstest]
653    fn test_open_positions_non_empty_array_errors() {
654        let err =
655            serde_json::from_str::<SpotOpenPositionsResponse>(r#"[{"posid": "123"}]"#).unwrap_err();
656        assert!(
657            err.to_string()
658                .contains("OpenPositions: expected empty array or object map"),
659            "unexpected error: {err}"
660        );
661    }
662}