Skip to main content

oanda_client/
pricing.rs

1//! OANDA pricing endpoint contracts.
2
3use crate::client::RequestParts;
4use crate::ids::{AccountID, CandleSpecification, InstrumentName, PricingComponent};
5use crate::models::{
6    Candlestick, CandlestickGranularity, CandlestickResponse, ClientPrice, HomeConversions,
7    WeeklyAlignment,
8};
9use crate::validation::Validate;
10use crate::{Client, Error, Result, Timestamp};
11use reqwest::Method;
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14
15/// Query parameters for `latest_candles`.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct LatestCandlesQuery {
19    /// List of candle specifications to get pricing for. \[required\]
20    #[serde(rename = "candleSpecifications")]
21    pub candle_specifications: Vec<CandleSpecification>,
22    /// The number of units used to calculate the volume-weighted average bid and ask prices in the returned
23    /// candles. \[default=1\]
24    #[serde(rename = "units", default, skip_serializing_if = "Option::is_none")]
25    #[serde(deserialize_with = "crate::decimal_wire::optional_number_or_string")]
26    pub units: Option<Decimal>,
27    /// A flag that controls whether the candlestick is “smoothed” or not. A smoothed candlestick uses the
28    /// previous candle’s close price as its open price, while an unsmoothed candlestick uses the first price
29    /// from its time range as its open price. \[default=False\]
30    #[serde(rename = "smooth", default, skip_serializing_if = "Option::is_none")]
31    pub smooth: Option<bool>,
32    /// The hour of the day (in the specified timezone) to use for granularities that have daily alignments.
33    /// \[default=17, minimum=0, maximum=23\]
34    #[serde(
35        rename = "dailyAlignment",
36        default,
37        skip_serializing_if = "Option::is_none"
38    )]
39    pub daily_alignment: Option<i64>,
40    /// The timezone to use for the dailyAlignment parameter. Candlesticks with daily alignment will be aligned
41    /// to the dailyAlignment hour within the alignmentTimezone. Note that the returned times will still be
42    /// represented in UTC. \[`default=America/New_York`\]
43    #[serde(
44        rename = "alignmentTimezone",
45        default,
46        skip_serializing_if = "Option::is_none"
47    )]
48    pub alignment_timezone: Option<String>,
49    /// The day of the week used for granularities that have weekly alignment. \[default=Friday\]
50    #[serde(
51        rename = "weeklyAlignment",
52        default,
53        skip_serializing_if = "Option::is_none"
54    )]
55    pub weekly_alignment: Option<WeeklyAlignment>,
56}
57
58impl Validate for LatestCandlesQuery {
59    fn validate(&self) -> Result<()> {
60        crate::validation::nonempty(&self.candle_specifications, "candleSpecifications")?;
61        for specification in &self.candle_specifications {
62            let mut parts = specification.as_str().split(':');
63            let valid = match (parts.next(), parts.next(), parts.next(), parts.next()) {
64                (Some(instrument), Some(granularity), Some(components), None) => {
65                    valid_instrument(instrument)
66                        && !granularity.is_empty()
67                        && valid_components(components)
68                }
69                _ => false,
70            };
71            if !valid {
72                return Err(Error::InvalidInput("invalid candle specification".into()));
73            }
74        }
75        crate::validation::daily_alignment(self.daily_alignment)
76    }
77}
78
79/// Successful response for `latest_candles`.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct LatestCandlesResponse {
83    /// The latest candle sticks.
84    #[serde(
85        rename = "latestCandles",
86        default,
87        skip_serializing_if = "Option::is_none"
88    )]
89    pub latest_candles: Option<Vec<CandlestickResponse>>,
90}
91
92impl Client {
93    /// Latest candles.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`crate::OperationError::Client`] for invalid input, admission, transport, or
98    /// malformed response failures. Provider refusals return [`crate::OperationError::Rejected`]
99    /// or [`crate::OperationError::RejectedUnreadable`] when the rejection body cannot be read.
100    pub async fn latest_candles(
101        &self,
102        account_id: &AccountID,
103        query: &LatestCandlesQuery,
104    ) -> std::result::Result<
105        crate::ApiResponse<LatestCandlesResponse>,
106        crate::OperationError<crate::GenericRejection>,
107    > {
108        let account_id_encoded = crate::client::segment(account_id.as_str());
109        let path = format!("/v3/accounts/{account_id_encoded}/candles/latest");
110        self.execute(RequestParts::new(Method::GET, &path).query(query)?)
111            .await
112    }
113}
114
115/// Query parameters for `pricing`.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct PricingQuery {
119    /// List of Instruments to get pricing for. \[required\]
120    #[serde(rename = "instruments")]
121    pub instruments: Vec<InstrumentName>,
122    /// Date/Time filter to apply to the response. Only prices and home conversions (if requested) with a time
123    /// later than this filter (i.e. the price has changed after the since time) will be provided, and are
124    /// filtered independently.
125    #[serde(rename = "since", default, skip_serializing_if = "Option::is_none")]
126    pub since: Option<Timestamp>,
127    /// Flag that enables the inclusion of the unitsAvailable field in the returned Price objects.
128    /// \[default=True\] Deprecated: Will be removed in a future API update.
129    #[serde(
130        rename = "includeUnitsAvailable",
131        default,
132        skip_serializing_if = "Option::is_none"
133    )]
134    pub include_units_available: Option<bool>,
135    /// Flag that enables the inclusion of the homeConversions field in the returned response. An entry will be
136    /// returned for each currency in the set of all base and quote currencies present in the requested
137    /// instruments list. \[default=False\]
138    #[serde(
139        rename = "includeHomeConversions",
140        default,
141        skip_serializing_if = "Option::is_none"
142    )]
143    pub include_home_conversions: Option<bool>,
144}
145
146impl Validate for PricingQuery {
147    fn validate(&self) -> Result<()> {
148        crate::validation::nonempty(&self.instruments, "instruments")
149    }
150}
151
152/// Successful response for `pricing`.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct PricingResponse {
156    /// The list of Price objects requested.
157    #[serde(rename = "prices")]
158    pub prices: Vec<ClientPrice>,
159    /// The list of home currency conversion factors requested. This field will only be present if
160    /// includeHomeConversions was set to true in the request.
161    #[serde(
162        rename = "homeConversions",
163        default,
164        skip_serializing_if = "Option::is_none"
165    )]
166    pub home_conversions: Option<Vec<HomeConversions>>,
167    /// The `DateTime` value to use for the “since” parameter in the next poll request.
168    #[serde(rename = "time", default, skip_serializing_if = "Option::is_none")]
169    pub time: Option<Timestamp>,
170}
171
172impl Client {
173    /// Pricing.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`crate::OperationError::Client`] for invalid input, admission, transport, or
178    /// malformed response failures. Provider refusals return [`crate::OperationError::Rejected`]
179    /// or [`crate::OperationError::RejectedUnreadable`] when the rejection body cannot be read.
180    pub async fn pricing(
181        &self,
182        account_id: &AccountID,
183        query: &PricingQuery,
184    ) -> std::result::Result<
185        crate::ApiResponse<PricingResponse>,
186        crate::OperationError<crate::GenericRejection>,
187    > {
188        let account_id_encoded = crate::client::segment(account_id.as_str());
189        let path = format!("/v3/accounts/{account_id_encoded}/pricing");
190        self.execute(RequestParts::new(Method::GET, &path).query(query)?)
191            .await
192    }
193}
194
195/// `stream_pricing` stream query parameters.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct StreamPricingQuery {
199    /// List of Instruments to stream Prices for. \[required\]
200    #[serde(rename = "instruments")]
201    pub instruments: Vec<InstrumentName>,
202    /// Flag that enables/disables the sending of a pricing snapshot when initially connecting to the stream.
203    /// \[default=True\]
204    #[serde(rename = "snapshot", default, skip_serializing_if = "Option::is_none")]
205    pub snapshot: Option<bool>,
206    /// Flag that enables the inclusion of the homeConversions field in the returned response. An entry will be
207    /// returned for each currency in the set of all base and quote currencies present in the requested
208    /// instruments list. \[default=False\]
209    #[serde(
210        rename = "includeHomeConversions",
211        default,
212        skip_serializing_if = "Option::is_none"
213    )]
214    pub include_home_conversions: Option<bool>,
215}
216
217impl Validate for StreamPricingQuery {
218    fn validate(&self) -> Result<()> {
219        crate::validation::nonempty(&self.instruments, "instruments")
220    }
221}
222
223/// Query parameters for `instrument_candles`.
224///
225/// The default uses the provider’s candle range, granularity, and alignment.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
227#[serde(rename_all = "camelCase")]
228pub struct InstrumentCandlesQuery {
229    /// The Price component(s) to get candlestick data for. \[default=M\]
230    #[serde(rename = "price", default, skip_serializing_if = "Option::is_none")]
231    pub price: Option<PricingComponent>,
232    /// The granularity of the candlesticks to fetch \[default=S5\]
233    #[serde(
234        rename = "granularity",
235        default,
236        skip_serializing_if = "Option::is_none"
237    )]
238    pub granularity: Option<CandlestickGranularity>,
239    /// The number of candlesticks to return in the response. Count should not be specified if both the start
240    /// and end parameters are provided, as the time range combined with the granularity will determine the
241    /// number of candlesticks to return. \[default=500, maximum=5000\]
242    #[serde(rename = "count", default, skip_serializing_if = "Option::is_none")]
243    pub count: Option<i64>,
244    /// The start of the time range to fetch candlesticks for.
245    #[serde(rename = "from", default, skip_serializing_if = "Option::is_none")]
246    pub from: Option<Timestamp>,
247    /// The end of the time range to fetch candlesticks for.
248    #[serde(rename = "to", default, skip_serializing_if = "Option::is_none")]
249    pub to: Option<Timestamp>,
250    /// A flag that controls whether the candlestick is “smoothed” or not. A smoothed candlestick uses the
251    /// previous candle’s close price as its open price, while an unsmoothed candlestick uses the first price
252    /// from its time range as its open price. \[default=False\]
253    #[serde(rename = "smooth", default, skip_serializing_if = "Option::is_none")]
254    pub smooth: Option<bool>,
255    /// A flag that controls whether the candlestick that is covered by the from time should be included in the
256    /// results. This flag enables clients to use the timestamp of the last completed candlestick received to
257    /// poll for future candlesticks but avoid receiving the previous candlestick repeatedly. \[default=True\]
258    #[serde(
259        rename = "includeFirst",
260        default,
261        skip_serializing_if = "Option::is_none"
262    )]
263    pub include_first: Option<bool>,
264    /// The hour of the day (in the specified timezone) to use for granularities that have daily alignments.
265    /// \[default=17, minimum=0, maximum=23\]
266    #[serde(
267        rename = "dailyAlignment",
268        default,
269        skip_serializing_if = "Option::is_none"
270    )]
271    pub daily_alignment: Option<i64>,
272    /// The timezone to use for the dailyAlignment parameter. Candlesticks with daily alignment will be aligned
273    /// to the dailyAlignment hour within the alignmentTimezone. Note that the returned times will still be
274    /// represented in UTC. \[`default=America/New_York`\]
275    #[serde(
276        rename = "alignmentTimezone",
277        default,
278        skip_serializing_if = "Option::is_none"
279    )]
280    pub alignment_timezone: Option<String>,
281    /// The day of the week used for granularities that have weekly alignment. \[default=Friday\]
282    #[serde(
283        rename = "weeklyAlignment",
284        default,
285        skip_serializing_if = "Option::is_none"
286    )]
287    pub weekly_alignment: Option<WeeklyAlignment>,
288    /// The number of units used to calculate the volume-weighted average bid and ask prices in the returned
289    /// candles. \[default=1\]
290    #[serde(rename = "units", default, skip_serializing_if = "Option::is_none")]
291    #[serde(deserialize_with = "crate::decimal_wire::optional_number_or_string")]
292    pub units: Option<Decimal>,
293}
294
295impl Validate for InstrumentCandlesQuery {
296    fn validate(&self) -> Result<()> {
297        if self
298            .price
299            .as_ref()
300            .is_some_and(|price| !valid_components(price.as_str()))
301        {
302            return Err(Error::InvalidInput("invalid pricing component".into()));
303        }
304        crate::validation::count(self.count, 5000)?;
305        crate::validation::daily_alignment(self.daily_alignment)
306    }
307}
308
309/// Successful response for `instrument_candles`.
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct InstrumentCandlesResponse {
313    /// The instrument whose Prices are represented by the candlesticks.
314    #[serde(
315        rename = "instrument",
316        default,
317        skip_serializing_if = "Option::is_none"
318    )]
319    pub instrument: Option<InstrumentName>,
320    /// The granularity of the candlesticks provided.
321    #[serde(
322        rename = "granularity",
323        default,
324        skip_serializing_if = "Option::is_none"
325    )]
326    pub granularity: Option<CandlestickGranularity>,
327    /// The list of candlesticks that satisfy the request.
328    #[serde(rename = "candles", default, skip_serializing_if = "Option::is_none")]
329    pub candles: Option<Vec<Candlestick>>,
330}
331
332impl Client {
333    /// Instrument candles.
334    ///
335    /// # Errors
336    ///
337    /// Returns [`crate::OperationError::Client`] for invalid input, admission, transport, or
338    /// malformed response failures. Provider refusals return [`crate::OperationError::Rejected`]
339    /// or [`crate::OperationError::RejectedUnreadable`] when the rejection body cannot be read.
340    pub async fn instrument_candles(
341        &self,
342        account_id: &AccountID,
343        instrument: &InstrumentName,
344        query: Option<&InstrumentCandlesQuery>,
345    ) -> std::result::Result<
346        crate::ApiResponse<InstrumentCandlesResponse>,
347        crate::OperationError<crate::GenericRejection>,
348    > {
349        let account_id_encoded = crate::client::segment(account_id.as_str());
350        let instrument_encoded = crate::client::segment(instrument.as_str());
351        let path =
352            format!("/v3/accounts/{account_id_encoded}/instruments/{instrument_encoded}/candles");
353        let request = RequestParts::new(Method::GET, &path);
354        let request = match query {
355            Some(query) => request.query(query)?,
356            None => request,
357        };
358        self.execute(request).await
359    }
360}
361
362fn valid_instrument(text: &str) -> bool {
363    let Some((base, quote)) = text.split_once('_') else {
364        return false;
365    };
366    !base.is_empty()
367        && !quote.is_empty()
368        && base
369            .bytes()
370            .chain(quote.bytes())
371            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
372}
373
374fn valid_components(text: &str) -> bool {
375    !text.is_empty()
376        && text.len() <= 3
377        && text.bytes().all(|byte| matches!(byte, b'B' | b'A' | b'M'))
378        && text
379            .as_bytes()
380            .iter()
381            .enumerate()
382            .all(|(index, byte)| !text.as_bytes()[..index].contains(byte))
383}