dukascopy_fx/api/
ticker.rs

1//! yfinance-style Ticker API for forex data.
2
3use crate::core::client::DukascopyClient;
4use crate::error::DukascopyError;
5use crate::models::{CurrencyExchange, CurrencyPair};
6use chrono::{DateTime, Duration, Utc};
7use std::str::FromStr;
8
9/// A forex ticker for fetching exchange rate data.
10///
11/// # Example
12///
13/// ```no_run
14/// use dukascopy_fx::Ticker;
15///
16/// # async fn example() -> dukascopy_fx::Result<()> {
17/// let ticker = Ticker::new("EUR", "USD");
18///
19/// // Get recent rate
20/// let rate = ticker.rate().await?;
21/// println!("EUR/USD: {}", rate.rate);
22///
23/// // Get historical data
24/// let history = ticker.history("1w").await?;
25/// # Ok(())
26/// # }
27/// ```
28#[derive(Debug, Clone)]
29pub struct Ticker {
30    pair: CurrencyPair,
31    interval: Duration,
32}
33
34impl Ticker {
35    /// Creates a new ticker for a currency pair.
36    #[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    /// Creates a ticker from a pair string like "EUR/USD" or "EURUSD".
45    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    /// Sets the data interval for historical queries.
54    pub fn interval(mut self, interval: Duration) -> Self {
55        self.interval = interval;
56        self
57    }
58
59    /// Returns the currency pair.
60    #[inline]
61    pub fn pair(&self) -> &CurrencyPair {
62        &self.pair
63    }
64
65    /// Returns the ticker symbol (e.g., "EURUSD").
66    #[inline]
67    pub fn symbol(&self) -> String {
68        self.pair.as_symbol()
69    }
70
71    // ==================== Data Fetching ====================
72
73    /// Fetches the exchange rate at a specific timestamp.
74    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    /// Fetches the most recent available exchange rate.
82    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    /// Fetches historical data for a time period.
88    ///
89    /// # Period Strings
90    /// - `"1d"` - 1 day
91    /// - `"5d"` - 5 days
92    /// - `"1w"` - 1 week
93    /// - `"1mo"` - 1 month (30 days)
94    /// - `"3mo"` - 3 months
95    /// - `"1y"` - 1 year
96    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    /// Fetches historical data between two dates.
104    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    // ==================== Convenience Constructors ====================
113
114    #[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
156// ============================================================================
157// Period Parsing
158// ============================================================================
159
160fn 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
197// ============================================================================
198// Batch Download
199// ============================================================================
200
201/// Downloads historical data for multiple tickers.
202pub 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
214/// Downloads historical data with custom date range.
215pub 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// ============================================================================
229// Tests
230// ============================================================================
231
232#[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}