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