Skip to main content

rust_okx/api/account/requests/
borrowing.rs

1use serde::Serialize;
2
3use crate::model::{
4    RequestValidationError, TradeMode, ValidateRequest, collection_length, exactly_one, non_empty,
5    non_empty_items, one_of, optional_non_empty, optional_one_of, optional_unsigned_integer_string,
6    positive_decimal_string, range_u64, reject_when_present,
7};
8
9/// Query parameters for maximum loan.
10#[derive(Debug, Clone, Serialize)]
11pub struct MaxLoanRequest {
12    #[serde(rename = "instId", skip_serializing_if = "Option::is_none")]
13    inst_id: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    ccy: Option<String>,
16    #[serde(rename = "mgnMode")]
17    mgn_mode: TradeMode,
18    #[serde(rename = "mgnCcy", skip_serializing_if = "Option::is_none")]
19    mgn_ccy: Option<String>,
20    #[serde(rename = "tradeQuoteCcy", skip_serializing_if = "Option::is_none")]
21    trade_quote_ccy: Option<String>,
22}
23
24impl MaxLoanRequest {
25    /// Create an instrument-based maximum-loan query.
26    ///
27    /// `inst_id` may contain one to five comma-separated instrument IDs, as
28    /// documented by OKX. Use [`Self::by_currency`] for Spot-mode manual-borrow
29    /// quota queries.
30    pub fn new(inst_id: impl Into<String>, mgn_mode: TradeMode) -> Self {
31        Self::by_instrument(inst_id, mgn_mode)
32    }
33
34    /// Create an instrument-based maximum-loan query.
35    pub fn by_instrument(inst_id: impl Into<String>, mgn_mode: TradeMode) -> Self {
36        Self {
37            mgn_mode,
38            inst_id: Some(inst_id.into()),
39            ccy: None,
40            mgn_ccy: None,
41            trade_quote_ccy: None,
42        }
43    }
44
45    /// Create a currency-based Spot-mode manual-borrow quota query.
46    pub fn by_currency(ccy: impl Into<String>) -> Self {
47        Self {
48            mgn_mode: TradeMode::Cross,
49            inst_id: None,
50            ccy: Some(ccy.into()),
51            mgn_ccy: None,
52            trade_quote_ccy: None,
53        }
54    }
55
56    /// Replace the selector with a currency-based Spot-mode query.
57    pub fn currency(mut self, ccy: impl Into<String>) -> Self {
58        self.inst_id = None;
59        self.ccy = Some(ccy.into());
60        self
61    }
62
63    /// Set the margin currency.
64    pub fn margin_currency(mut self, mgn_ccy: impl Into<String>) -> Self {
65        self.mgn_ccy = Some(mgn_ccy.into());
66        self
67    }
68
69    /// Set the trade quote currency.
70    pub fn trade_quote_currency(mut self, trade_quote_ccy: impl Into<String>) -> Self {
71        self.trade_quote_ccy = Some(trade_quote_ccy.into());
72        self
73    }
74}
75
76/// Query parameters for interest-accrued records.
77#[derive(Debug, Clone, Default, Serialize)]
78pub struct InterestAccruedRequest {
79    #[serde(rename = "instId", skip_serializing_if = "Option::is_none")]
80    inst_id: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    ccy: Option<String>,
83    #[serde(rename = "mgnMode", skip_serializing_if = "Option::is_none")]
84    mgn_mode: Option<TradeMode>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    after: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    before: Option<String>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    limit: Option<u32>,
91}
92
93impl InterestAccruedRequest {
94    /// Create an empty interest-accrued query.
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Set the instrument ID filter.
100    pub fn inst_id(mut self, inst_id: impl Into<String>) -> Self {
101        self.inst_id = Some(inst_id.into());
102        self
103    }
104
105    /// Set the currency filter.
106    pub fn currency(mut self, ccy: impl Into<String>) -> Self {
107        self.ccy = Some(ccy.into());
108        self
109    }
110
111    /// Set the margin mode filter.
112    pub fn margin_mode(mut self, mgn_mode: TradeMode) -> Self {
113        self.mgn_mode = Some(mgn_mode);
114        self
115    }
116
117    /// Return records after this pagination cursor.
118    pub fn after(mut self, after: impl Into<String>) -> Self {
119        self.after = Some(after.into());
120        self
121    }
122
123    /// Return records before this pagination cursor.
124    pub fn before(mut self, before: impl Into<String>) -> Self {
125        self.before = Some(before.into());
126        self
127    }
128
129    /// Set the maximum number of rows to return.
130    pub fn limit(mut self, limit: u32) -> Self {
131        self.limit = Some(limit);
132        self
133    }
134}
135
136/// Request body for borrow/repay.
137#[derive(Debug, Clone, Serialize)]
138pub struct BorrowRepayRequest {
139    ccy: String,
140    side: String,
141    amt: String,
142    #[serde(rename = "ordId", skip_serializing_if = "Option::is_none")]
143    ord_id: Option<String>,
144}
145
146impl BorrowRepayRequest {
147    /// Create a borrow/repay request.
148    pub fn new(ccy: impl Into<String>, side: impl Into<String>, amt: impl Into<String>) -> Self {
149        Self {
150            ccy: ccy.into(),
151            side: side.into(),
152            amt: amt.into(),
153            ord_id: None,
154        }
155    }
156
157    /// Set the related order ID.
158    pub fn order_id(mut self, ord_id: impl Into<String>) -> Self {
159        self.ord_id = Some(ord_id.into());
160        self
161    }
162}
163
164/// Query parameters for borrow/repay history.
165#[derive(Debug, Clone, Default, Serialize)]
166pub struct BorrowRepayHistoryRequest {
167    #[serde(skip_serializing_if = "Option::is_none")]
168    ccy: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    after: Option<String>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    before: Option<String>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    limit: Option<u32>,
175}
176
177impl BorrowRepayHistoryRequest {
178    /// Create an empty borrow/repay-history query.
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// Set the currency filter.
184    pub fn currency(mut self, ccy: impl Into<String>) -> Self {
185        self.ccy = Some(ccy.into());
186        self
187    }
188
189    /// Return records after this pagination cursor.
190    pub fn after(mut self, after: impl Into<String>) -> Self {
191        self.after = Some(after.into());
192        self
193    }
194
195    /// Return records before this pagination cursor.
196    pub fn before(mut self, before: impl Into<String>) -> Self {
197        self.before = Some(before.into());
198        self
199    }
200
201    /// Set the maximum number of rows to return.
202    pub fn limit(mut self, limit: u32) -> Self {
203        self.limit = Some(limit);
204        self
205    }
206}
207
208/// Query parameters for interest limits.
209#[derive(Debug, Clone, Default, Serialize)]
210pub struct InterestLimitsRequest {
211    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
212    limit_type: Option<String>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    ccy: Option<String>,
215}
216
217impl InterestLimitsRequest {
218    /// Create an empty interest-limits query.
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    /// Set the OKX interest-limit type.
224    pub fn limit_type(mut self, limit_type: impl Into<String>) -> Self {
225        self.limit_type = Some(limit_type.into());
226        self
227    }
228
229    /// Set the currency filter.
230    pub fn currency(mut self, ccy: impl Into<String>) -> Self {
231        self.ccy = Some(ccy.into());
232        self
233    }
234}
235
236fn validate_pagination(
237    after: Option<&str>,
238    before: Option<&str>,
239    limit: Option<u32>,
240) -> Result<(), RequestValidationError> {
241    optional_unsigned_integer_string("after", after)?;
242    optional_unsigned_integer_string("before", before)?;
243    if let Some(limit) = limit {
244        range_u64("limit", u64::from(limit), 1, 100)?;
245    }
246    Ok(())
247}
248
249impl ValidateRequest for MaxLoanRequest {
250    fn validate(&self) -> Result<(), RequestValidationError> {
251        match &self.mgn_mode {
252            TradeMode::Cross | TradeMode::Isolated => {}
253            _ => {
254                return Err(RequestValidationError::InvalidFormat {
255                    field: "mgnMode",
256                    expected: "cross or isolated",
257                });
258            }
259        }
260        optional_non_empty("instId", self.inst_id.as_deref())?;
261        optional_non_empty("ccy", self.ccy.as_deref())?;
262        exactly_one("instId, ccy", &[self.inst_id.is_some(), self.ccy.is_some()])?;
263
264        if let Some(inst_ids) = self.inst_id.as_deref() {
265            let instruments: Vec<_> = inst_ids.split(',').collect();
266            collection_length("instId", instruments.len(), 1, 5)?;
267            non_empty_items("instId", instruments)?;
268        }
269
270        optional_non_empty("mgnCcy", self.mgn_ccy.as_deref())?;
271        optional_non_empty("tradeQuoteCcy", self.trade_quote_ccy.as_deref())?;
272        if self.ccy.is_some() {
273            if !matches!(self.mgn_mode, TradeMode::Cross) {
274                return Err(RequestValidationError::InvalidFormat {
275                    field: "mgnMode",
276                    expected: "cross when ccy is used",
277                });
278            }
279            reject_when_present("mgnCcy", self.mgn_ccy.as_ref(), "ccy is used")?;
280            reject_when_present(
281                "tradeQuoteCcy",
282                self.trade_quote_ccy.as_ref(),
283                "ccy is used",
284            )?;
285        }
286        Ok(())
287    }
288}
289
290impl ValidateRequest for InterestAccruedRequest {
291    fn validate(&self) -> Result<(), RequestValidationError> {
292        optional_non_empty("instId", self.inst_id.as_deref())?;
293        optional_non_empty("ccy", self.ccy.as_deref())?;
294        if let Some(mode) = &self.mgn_mode {
295            match mode {
296                TradeMode::Cross | TradeMode::Isolated => {}
297                _ => {
298                    return Err(RequestValidationError::InvalidFormat {
299                        field: "mgnMode",
300                        expected: "cross or isolated",
301                    });
302                }
303            }
304        }
305        validate_pagination(self.after.as_deref(), self.before.as_deref(), self.limit)
306    }
307}
308
309impl ValidateRequest for BorrowRepayRequest {
310    fn validate(&self) -> Result<(), RequestValidationError> {
311        non_empty("ccy", &self.ccy)?;
312        one_of("side", &self.side, &["borrow", "repay"], "borrow or repay")?;
313        positive_decimal_string("amt", &self.amt)?;
314        optional_non_empty("ordId", self.ord_id.as_deref())
315    }
316}
317
318impl ValidateRequest for BorrowRepayHistoryRequest {
319    fn validate(&self) -> Result<(), RequestValidationError> {
320        optional_non_empty("ccy", self.ccy.as_deref())?;
321        validate_pagination(self.after.as_deref(), self.before.as_deref(), self.limit)
322    }
323}
324
325impl ValidateRequest for InterestLimitsRequest {
326    fn validate(&self) -> Result<(), RequestValidationError> {
327        optional_one_of(
328            "type",
329            self.limit_type.as_deref(),
330            &["1", "2"],
331            "1 (loan quota) or 2 (interest rate)",
332        )?;
333        optional_non_empty("ccy", self.ccy.as_deref())?;
334        Ok(())
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn max_loan_accepts_currency_selector() {
344        MaxLoanRequest::by_currency("USDT").validate().unwrap();
345    }
346
347    #[test]
348    fn max_loan_rejects_more_than_five_instruments() {
349        let request = MaxLoanRequest::new("A-B,B-C,C-D,D-E,E-F,F-G", TradeMode::Cross);
350        assert!(request.validate().is_err());
351    }
352}