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