1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct LatestCandlesQuery {
19 #[serde(rename = "candleSpecifications")]
21 pub candle_specifications: Vec<CandleSpecification>,
22 #[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 #[serde(rename = "smooth", default, skip_serializing_if = "Option::is_none")]
31 pub smooth: Option<bool>,
32 #[serde(
35 rename = "dailyAlignment",
36 default,
37 skip_serializing_if = "Option::is_none"
38 )]
39 pub daily_alignment: Option<i64>,
40 #[serde(
44 rename = "alignmentTimezone",
45 default,
46 skip_serializing_if = "Option::is_none"
47 )]
48 pub alignment_timezone: Option<String>,
49 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct LatestCandlesResponse {
83 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct PricingQuery {
119 #[serde(rename = "instruments")]
121 pub instruments: Vec<InstrumentName>,
122 #[serde(rename = "since", default, skip_serializing_if = "Option::is_none")]
126 pub since: Option<Timestamp>,
127 #[serde(
130 rename = "includeUnitsAvailable",
131 default,
132 skip_serializing_if = "Option::is_none"
133 )]
134 pub include_units_available: Option<bool>,
135 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct PricingResponse {
156 #[serde(rename = "prices")]
158 pub prices: Vec<ClientPrice>,
159 #[serde(
162 rename = "homeConversions",
163 default,
164 skip_serializing_if = "Option::is_none"
165 )]
166 pub home_conversions: Option<Vec<HomeConversions>>,
167 #[serde(rename = "time", default, skip_serializing_if = "Option::is_none")]
169 pub time: Option<Timestamp>,
170}
171
172impl Client {
173 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct StreamPricingQuery {
199 #[serde(rename = "instruments")]
201 pub instruments: Vec<InstrumentName>,
202 #[serde(rename = "snapshot", default, skip_serializing_if = "Option::is_none")]
205 pub snapshot: Option<bool>,
206 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
227#[serde(rename_all = "camelCase")]
228pub struct InstrumentCandlesQuery {
229 #[serde(rename = "price", default, skip_serializing_if = "Option::is_none")]
231 pub price: Option<PricingComponent>,
232 #[serde(
234 rename = "granularity",
235 default,
236 skip_serializing_if = "Option::is_none"
237 )]
238 pub granularity: Option<CandlestickGranularity>,
239 #[serde(rename = "count", default, skip_serializing_if = "Option::is_none")]
243 pub count: Option<i64>,
244 #[serde(rename = "from", default, skip_serializing_if = "Option::is_none")]
246 pub from: Option<Timestamp>,
247 #[serde(rename = "to", default, skip_serializing_if = "Option::is_none")]
249 pub to: Option<Timestamp>,
250 #[serde(rename = "smooth", default, skip_serializing_if = "Option::is_none")]
254 pub smooth: Option<bool>,
255 #[serde(
259 rename = "includeFirst",
260 default,
261 skip_serializing_if = "Option::is_none"
262 )]
263 pub include_first: Option<bool>,
264 #[serde(
267 rename = "dailyAlignment",
268 default,
269 skip_serializing_if = "Option::is_none"
270 )]
271 pub daily_alignment: Option<i64>,
272 #[serde(
276 rename = "alignmentTimezone",
277 default,
278 skip_serializing_if = "Option::is_none"
279 )]
280 pub alignment_timezone: Option<String>,
281 #[serde(
283 rename = "weeklyAlignment",
284 default,
285 skip_serializing_if = "Option::is_none"
286 )]
287 pub weekly_alignment: Option<WeeklyAlignment>,
288 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct InstrumentCandlesResponse {
313 #[serde(
315 rename = "instrument",
316 default,
317 skip_serializing_if = "Option::is_none"
318 )]
319 pub instrument: Option<InstrumentName>,
320 #[serde(
322 rename = "granularity",
323 default,
324 skip_serializing_if = "Option::is_none"
325 )]
326 pub granularity: Option<CandlestickGranularity>,
327 #[serde(rename = "candles", default, skip_serializing_if = "Option::is_none")]
329 pub candles: Option<Vec<Candlestick>>,
330}
331
332impl Client {
333 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}