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}