Skip to main content

nordnet_api/resources/
instruments.rs

1//! Resource methods for the `instruments` API group.
2//!
3//! # Operations
4//!
5//! | Method | Op | Path |
6//! |--------|----|------|
7//! | GET | `lookup` | `/instruments/lookup/{lookup_type}/{lookup}` |
8//! | GET | `list_types` | `/instruments/types` |
9//! | GET | `get_type` | `/instruments/types/{instrument_type}` |
10//! | GET | `list_underlyings` | `/instruments/underlyings/{derivative_type}/{currency}` |
11//! | GET | `get_instrument_suitability` | `/instruments/validation/suitability/{instrument_id}` |
12//! | GET | `get_instrument` | `/instruments/{instrument_id}` |
13//! | GET | `list_leverages` | `/instruments/{instrument_id}/leverages` |
14//! | GET | `get_leverage_filters` | `/instruments/{instrument_id}/leverages/filters` |
15//! | GET | `list_instrument_trades` | `/instruments/{instrument_id}/trades` |
16//!
17//!
18//! ## Naming
19//!
20//! Two ops are renamed from their docs name so they can co-exist on
21//! [`Client`] alongside same-named ops in other groups (Rust resolves all
22//! resource methods onto a single `Client` impl):
23//!
24//! - `list_trades` -> `list_instrument_trades` — to coexist with
25//!   `tradables::list_tradable_trades` and the future
26//!   `accounts::list_trades`.
27//! - `get_suitability` -> `get_instrument_suitability` — to coexist with
28//!   `tradables::get_suitability`.
29//!
30//! Phase 3X may pick a uniform naming scheme.
31//!
32//!
33//! ## 204 No Content
34//!
35//! All ops except `list_types` and `get_leverage_filters` document a 204
36//! response. The base [`Client::get`] surfaces an empty body as a
37//! [`Error::Decode`]; each method here maps that case to an empty `Vec`
38//! (mirroring the `tradables` precedent). `get_leverage_filters` returns a
39//! single `LeverageFilter` (no array shape) so 204 is not mapped.
40
41use crate::client::Client;
42use crate::error::Error;
43use nordnet_model::ids::{InstrumentId, IssuerId};
44use nordnet_model::models::instruments::{
45    Instrument, InstrumentEligibility, InstrumentPublicTrades, InstrumentType, LeverageFilter,
46};
47
48/// Optional query parameters for [`Client::list_leverages`] and
49/// [`Client::get_leverage_filters`].
50///
51/// All six fields are documented as optional. Built with
52/// `LeveragesQuery::default()` and field-by-field assignment, then passed
53/// by reference to the resource methods.
54#[derive(Debug, Clone, Default)]
55pub struct LeveragesQuery<'a> {
56    /// Show only leverage instruments with a specific currency.
57    pub currency: Option<&'a str>,
58    /// Show only leverage instruments with a specific expiration date
59    /// (`YYYY-MM-DD`).
60    pub expiration_date: Option<&'a str>,
61    /// Show only instruments with a specific instrument group type.
62    pub instrument_group_type: Option<&'a str>,
63    /// Show only instruments with a specific instrument type.
64    pub instrument_type: Option<&'a str>,
65    /// Show only leverage instruments from a specific issuer.
66    pub issuer_id: Option<IssuerId>,
67    /// Show only leverage instruments with a specific market view
68    /// (`D` or `U`).
69    pub market_view: Option<&'a str>,
70}
71
72/// Build the encoded query string for the leverages endpoints.
73///
74/// Uses `reqwest::Url::query_pairs_mut` so all percent-encoding follows
75/// the standard URL form rules. The placeholder host is never sent
76/// anywhere — only the encoded query suffix is extracted.
77fn build_leverages_query(filters: &LeveragesQuery<'_>) -> String {
78    let mut url = match reqwest::Url::parse("http://_/") {
79        Ok(u) => u,
80        // The literal above is a valid absolute URL — this branch is
81        // unreachable in practice. Returning an empty string keeps the
82        // function total without panicking.
83        Err(_) => return String::new(),
84    };
85    {
86        let mut pairs = url.query_pairs_mut();
87        if let Some(v) = filters.currency {
88            pairs.append_pair("currency", v);
89        }
90        if let Some(v) = filters.expiration_date {
91            pairs.append_pair("expiration_date", v);
92        }
93        if let Some(v) = filters.instrument_group_type {
94            pairs.append_pair("instrument_group_type", v);
95        }
96        if let Some(v) = filters.instrument_type {
97            pairs.append_pair("instrument_type", v);
98        }
99        if let Some(v) = filters.issuer_id {
100            pairs.append_pair("issuer_id", &v.0.to_string());
101        }
102        if let Some(v) = filters.market_view {
103            pairs.append_pair("market_view", v);
104        }
105    }
106    url.query().unwrap_or("").to_owned()
107}
108
109impl Client {
110    /// `GET /instruments/lookup/{lookup_type}/{lookup}` — Lookup specific
111    /// instruments with predefined fields.
112    ///
113    /// `lookup_type` must be one of the documented enum values:
114    /// `market_id_identifier` or `isin_code_currency_market_id`.
115    /// `lookup` is formatted as `[market_id]:[identifier]` or
116    /// `[isin]:[currency]:[market_id]` respectively. Multiple entries are
117    /// comma-separated. Pass-through `&str` for now.
118    ///
119    /// Returns an empty `Vec` on 204 No Content.
120    ///
121    /// # Errors
122    ///
123    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
124    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
125    /// (503).
126    #[doc(alias = "GET /instruments/lookup/{lookup_type}/{lookup}")]
127    pub async fn lookup(&self, lookup_type: &str, lookup: &str) -> Result<Vec<Instrument>, Error> {
128        let path = format!("/instruments/lookup/{lookup_type}/{lookup}");
129        match self.get::<Vec<Instrument>>(&path).await {
130            Ok(v) => Ok(v),
131            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
132            Err(e) => Err(e),
133        }
134    }
135
136    /// `GET /instruments/types` — Returns all Nordnet instrument types.
137    ///
138    /// # Errors
139    ///
140    /// [`Error::Unauthorized`] (401), [`Error::TooManyRequests`] (429),
141    /// [`Error::ServiceUnavailable`] (503).
142    #[doc(alias = "GET /instruments/types")]
143    pub async fn list_types(&self) -> Result<Vec<InstrumentType>, Error> {
144        self.get::<Vec<InstrumentType>>("/instruments/types").await
145    }
146
147    /// `GET /instruments/types/{instrument_type}` — Returns information
148    /// about one or more Nordnet instrument types.
149    ///
150    /// `instrument_type` is one or more comma-separated type codes
151    /// (pass-through `&str`).
152    ///
153    /// Returns an empty `Vec` on 204 No Content.
154    ///
155    /// # Errors
156    ///
157    /// [`Error::Unauthorized`] (401), [`Error::TooManyRequests`] (429),
158    /// [`Error::ServiceUnavailable`] (503).
159    #[doc(alias = "GET /instruments/types/{instrument_type}")]
160    pub async fn get_type(&self, instrument_type: &str) -> Result<Vec<InstrumentType>, Error> {
161        let path = format!("/instruments/types/{instrument_type}");
162        match self.get::<Vec<InstrumentType>>(&path).await {
163            Ok(v) => Ok(v),
164            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
165            Err(e) => Err(e),
166        }
167    }
168
169    /// `GET /instruments/underlyings/{derivative_type}/{currency}` —
170    /// Returns instruments that are underlyings for a specific type of
171    /// instruments.
172    ///
173    /// `derivative_type` is one of `leverage` or `option_pair`. `currency`
174    /// is the derivative currency (note the underlying may have a
175    /// different currency). Both pass-through `&str`.
176    ///
177    /// Returns an empty `Vec` on 204 No Content.
178    ///
179    /// # Errors
180    ///
181    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
182    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
183    /// (503).
184    #[doc(alias = "GET /instruments/underlyings/{derivative_type}/{currency}")]
185    pub async fn list_underlyings(
186        &self,
187        derivative_type: &str,
188        currency: &str,
189    ) -> Result<Vec<Instrument>, Error> {
190        let path = format!("/instruments/underlyings/{derivative_type}/{currency}");
191        match self.get::<Vec<Instrument>>(&path).await {
192            Ok(v) => Ok(v),
193            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
194            Err(e) => Err(e),
195        }
196    }
197
198    /// `GET /instruments/validation/suitability/{instrument_id}` —
199    /// Returns the customer's trading eligibility for the given
200    /// instrument(s).
201    ///
202    /// Renamed from the docs op `get_suitability` to
203    /// `get_instrument_suitability` to coexist with
204    /// `tradables::get_suitability` on the same `Client` impl.
205    ///
206    /// Returns an empty `Vec` on 204 No Content.
207    ///
208    /// # Errors
209    ///
210    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
211    /// [`Error::Forbidden`] (403; documented for anonymous sessions and
212    /// returned with an empty body), [`Error::TooManyRequests`] (429),
213    /// [`Error::ServiceUnavailable`] (503).
214    #[doc(alias = "GET /instruments/validation/suitability/{instrument_id}")]
215    pub async fn get_instrument_suitability(
216        &self,
217        instrument_id: InstrumentId,
218    ) -> Result<Vec<InstrumentEligibility>, Error> {
219        let path = format!("/instruments/validation/suitability/{instrument_id}");
220        match self.get::<Vec<InstrumentEligibility>>(&path).await {
221            Ok(v) => Ok(v),
222            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
223            Err(e) => Err(e),
224        }
225    }
226
227    /// `GET /instruments/{instrument_id}` — Returns instrument information
228    /// for the given instrument ID(s).
229    ///
230    /// Returns an empty `Vec` on 204 No Content.
231    ///
232    /// # Errors
233    ///
234    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
235    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
236    /// (503).
237    #[doc(alias = "GET /instruments/{instrument_id}")]
238    pub async fn get_instrument(
239        &self,
240        instrument_id: InstrumentId,
241    ) -> Result<Vec<Instrument>, Error> {
242        let path = format!("/instruments/{instrument_id}");
243        match self.get::<Vec<Instrument>>(&path).await {
244            Ok(v) => Ok(v),
245            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
246            Err(e) => Err(e),
247        }
248    }
249
250    /// `GET /instruments/{instrument_id}/leverages` — Returns a list of
251    /// leverage instruments that have the current instrument as
252    /// underlying.
253    ///
254    /// Filters are passed as a [`LeveragesQuery`]; pass
255    /// `&LeveragesQuery::default()` for "no filters".
256    ///
257    /// Returns an empty `Vec` on 204 No Content.
258    ///
259    /// # Errors
260    ///
261    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
262    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
263    /// (503).
264    #[doc(alias = "GET /instruments/{instrument_id}/leverages")]
265    pub async fn list_leverages(
266        &self,
267        instrument_id: InstrumentId,
268        filters: LeveragesQuery<'_>,
269    ) -> Result<Vec<Instrument>, Error> {
270        let qs = build_leverages_query(&filters);
271        let path = if qs.is_empty() {
272            format!("/instruments/{instrument_id}/leverages")
273        } else {
274            format!("/instruments/{instrument_id}/leverages?{qs}")
275        };
276        match self.get::<Vec<Instrument>>(&path).await {
277            Ok(v) => Ok(v),
278            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
279            Err(e) => Err(e),
280        }
281    }
282
283    /// `GET /instruments/{instrument_id}/leverages/filters` — Returns
284    /// valid leverage instruments filter values for the given underlying.
285    ///
286    /// # Errors
287    ///
288    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
289    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
290    /// (503).
291    #[doc(alias = "GET /instruments/{instrument_id}/leverages/filters")]
292    pub async fn get_leverage_filters(
293        &self,
294        instrument_id: InstrumentId,
295    ) -> Result<LeverageFilter, Error> {
296        let path = format!("/instruments/{instrument_id}/leverages/filters");
297        self.get::<LeverageFilter>(&path).await
298    }
299
300    /// `GET /instruments/{instrument_id}/trades` — Returns the public
301    /// trades belonging to one or more instruments.
302    ///
303    /// Renamed from the docs op `list_trades` to `list_instrument_trades`
304    /// to coexist with `tradables::list_tradable_trades` and the future
305    /// `accounts::list_trades` on the same `Client` impl.
306    ///
307    /// Returns an empty `Vec` on 204 No Content.
308    ///
309    /// # Errors
310    ///
311    /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
312    /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
313    /// (503).
314    #[doc(alias = "GET /instruments/{instrument_id}/trades")]
315    pub async fn list_instrument_trades(
316        &self,
317        instrument_id: InstrumentId,
318    ) -> Result<Vec<InstrumentPublicTrades>, Error> {
319        let path = format!("/instruments/{instrument_id}/trades");
320        match self.get::<Vec<InstrumentPublicTrades>>(&path).await {
321            Ok(v) => Ok(v),
322            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
323            Err(e) => Err(e),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn build_query_empty_when_no_filters() {
334        let qs = build_leverages_query(&LeveragesQuery::default());
335        assert_eq!(qs, "");
336    }
337
338    #[test]
339    fn build_query_includes_all_filters_in_order() {
340        let qs = build_leverages_query(&LeveragesQuery {
341            currency: Some("SEK"),
342            expiration_date: Some("2025-12-19"),
343            instrument_group_type: Some("LEVERAGE"),
344            instrument_type: Some("WNT"),
345            issuer_id: Some(IssuerId(42)),
346            market_view: Some("U"),
347        });
348        assert_eq!(
349            qs,
350            "currency=SEK&expiration_date=2025-12-19&instrument_group_type=LEVERAGE&instrument_type=WNT&issuer_id=42&market_view=U"
351        );
352    }
353
354    #[test]
355    fn build_query_percent_encodes_special_chars() {
356        let qs = build_leverages_query(&LeveragesQuery {
357            currency: Some("a&b"),
358            ..LeveragesQuery::default()
359        });
360        assert_eq!(qs, "currency=a%26b");
361    }
362}