dukascopy_fx/api/
ticker.rs1use crate::core::client::DukascopyClient;
4use crate::error::DukascopyError;
5use crate::models::{CurrencyExchange, CurrencyPair};
6use chrono::{DateTime, Duration, Utc};
7use std::str::FromStr;
8
9#[derive(Debug, Clone)]
29pub struct Ticker {
30 pair: CurrencyPair,
31 interval: Duration,
32}
33
34impl Ticker {
35 #[inline]
37 pub fn new(from: &str, to: &str) -> Self {
38 Self {
39 pair: CurrencyPair::new(from, to),
40 interval: Duration::hours(1),
41 }
42 }
43
44 pub fn parse(pair: &str) -> Result<Self, DukascopyError> {
46 let currency_pair: CurrencyPair = pair.parse()?;
47 Ok(Self {
48 pair: currency_pair,
49 interval: Duration::hours(1),
50 })
51 }
52
53 pub fn interval(mut self, interval: Duration) -> Self {
55 self.interval = interval;
56 self
57 }
58
59 #[inline]
61 pub fn pair(&self) -> &CurrencyPair {
62 &self.pair
63 }
64
65 #[inline]
67 pub fn symbol(&self) -> String {
68 self.pair.as_symbol()
69 }
70
71 pub async fn rate_at(
75 &self,
76 timestamp: DateTime<Utc>,
77 ) -> Result<CurrencyExchange, DukascopyError> {
78 DukascopyClient::get_exchange_rate(&self.pair, timestamp).await
79 }
80
81 pub async fn rate(&self) -> Result<CurrencyExchange, DukascopyError> {
83 let timestamp = Utc::now() - Duration::hours(1);
84 self.rate_at(timestamp).await
85 }
86
87 pub async fn history(&self, period: &str) -> Result<Vec<CurrencyExchange>, DukascopyError> {
97 let duration = parse_period(period)?;
98 let end = Utc::now() - Duration::hours(1);
99 let start = end - duration;
100 DukascopyClient::get_exchange_rates_range(&self.pair, start, end, self.interval).await
101 }
102
103 pub async fn history_range(
105 &self,
106 start: DateTime<Utc>,
107 end: DateTime<Utc>,
108 ) -> Result<Vec<CurrencyExchange>, DukascopyError> {
109 DukascopyClient::get_exchange_rates_range(&self.pair, start, end, self.interval).await
110 }
111
112 #[inline]
115 pub fn eur_usd() -> Self {
116 Self::new("EUR", "USD")
117 }
118 #[inline]
119 pub fn gbp_usd() -> Self {
120 Self::new("GBP", "USD")
121 }
122 #[inline]
123 pub fn usd_jpy() -> Self {
124 Self::new("USD", "JPY")
125 }
126 #[inline]
127 pub fn usd_chf() -> Self {
128 Self::new("USD", "CHF")
129 }
130 #[inline]
131 pub fn aud_usd() -> Self {
132 Self::new("AUD", "USD")
133 }
134 #[inline]
135 pub fn usd_cad() -> Self {
136 Self::new("USD", "CAD")
137 }
138 #[inline]
139 pub fn xau_usd() -> Self {
140 Self::new("XAU", "USD")
141 }
142 #[inline]
143 pub fn xag_usd() -> Self {
144 Self::new("XAG", "USD")
145 }
146}
147
148impl FromStr for Ticker {
149 type Err = DukascopyError;
150
151 fn from_str(s: &str) -> Result<Self, Self::Err> {
152 Ticker::parse(s)
153 }
154}
155
156fn parse_period(period: &str) -> Result<Duration, DukascopyError> {
161 let period = period.trim().to_lowercase();
162
163 let (num_str, unit) = if period.ends_with("mo") {
164 (&period[..period.len() - 2], "mo")
165 } else if period.ends_with('d') {
166 (&period[..period.len() - 1], "d")
167 } else if period.ends_with('w') {
168 (&period[..period.len() - 1], "w")
169 } else if period.ends_with('y') {
170 (&period[..period.len() - 1], "y")
171 } else {
172 return Err(DukascopyError::InvalidRequest(format!(
173 "Invalid period format: '{}'. Use '1d', '1w', '1mo', '1y'",
174 period
175 )));
176 };
177
178 let num: i64 = num_str.parse().map_err(|_| {
179 DukascopyError::InvalidRequest(format!("Invalid period number in '{}'", period))
180 })?;
181
182 if num <= 0 {
183 return Err(DukascopyError::InvalidRequest(
184 "Period must be positive".to_string(),
185 ));
186 }
187
188 Ok(match unit {
189 "d" => Duration::days(num),
190 "w" => Duration::weeks(num),
191 "mo" => Duration::days(num * 30),
192 "y" => Duration::days(num * 365),
193 _ => unreachable!(),
194 })
195}
196
197pub async fn download(
203 tickers: &[Ticker],
204 period: &str,
205) -> Result<Vec<(Ticker, Vec<CurrencyExchange>)>, DukascopyError> {
206 let mut results = Vec::with_capacity(tickers.len());
207 for ticker in tickers {
208 let history = ticker.history(period).await?;
209 results.push((ticker.clone(), history));
210 }
211 Ok(results)
212}
213
214pub async fn download_range(
216 tickers: &[Ticker],
217 start: DateTime<Utc>,
218 end: DateTime<Utc>,
219) -> Result<Vec<(Ticker, Vec<CurrencyExchange>)>, DukascopyError> {
220 let mut results = Vec::with_capacity(tickers.len());
221 for ticker in tickers {
222 let history = ticker.history_range(start, end).await?;
223 results.push((ticker.clone(), history));
224 }
225 Ok(results)
226}
227
228#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_ticker_new() {
238 let ticker = Ticker::new("EUR", "USD");
239 assert_eq!(ticker.symbol(), "EURUSD");
240 }
241
242 #[test]
243 fn test_ticker_parse() {
244 let ticker = Ticker::parse("EUR/USD").unwrap();
245 assert_eq!(ticker.symbol(), "EURUSD");
246
247 let ticker = Ticker::parse("USDJPY").unwrap();
248 assert_eq!(ticker.symbol(), "USDJPY");
249 }
250
251 #[test]
252 fn test_from_str() {
253 let ticker: Ticker = "EUR/USD".parse().unwrap();
254 assert_eq!(ticker.symbol(), "EURUSD");
255 }
256
257 #[test]
258 fn test_convenience_constructors() {
259 assert_eq!(Ticker::eur_usd().symbol(), "EURUSD");
260 assert_eq!(Ticker::usd_jpy().symbol(), "USDJPY");
261 assert_eq!(Ticker::xau_usd().symbol(), "XAUUSD");
262 }
263
264 #[test]
265 fn test_parse_period() {
266 assert_eq!(parse_period("1d").unwrap(), Duration::days(1));
267 assert_eq!(parse_period("5d").unwrap(), Duration::days(5));
268 assert_eq!(parse_period("1w").unwrap(), Duration::weeks(1));
269 assert_eq!(parse_period("1mo").unwrap(), Duration::days(30));
270 assert_eq!(parse_period("1y").unwrap(), Duration::days(365));
271 assert_eq!(parse_period("1D").unwrap(), Duration::days(1));
272 }
273
274 #[test]
275 fn test_parse_period_invalid() {
276 assert!(parse_period("abc").is_err());
277 assert!(parse_period("0d").is_err());
278 assert!(parse_period("-1d").is_err());
279 }
280
281 #[test]
282 fn test_ticker_interval() {
283 let ticker = Ticker::new("EUR", "USD").interval(Duration::minutes(30));
284 assert_eq!(ticker.interval, Duration::minutes(30));
285 }
286}