Skip to main content

finance_query/adapters/polygon/crypto/
aggregates.rs

1//! Crypto aggregate bar endpoints: OHLCV bars, previous close, grouped daily, daily open/close.
2
3use serde::{Deserialize, Serialize};
4
5use crate::adapters::common::encode_path_segment;
6use crate::error::{FinanceError, Result};
7
8use super::super::build_client;
9use super::super::models::*;
10
11/// Daily open/close response for a crypto pair.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct CryptoDailyOpenClose {
15    /// The "from" symbol of the pair (e.g., `BTC`).
16    pub symbol: Option<String>,
17    /// Whether the response is adjusted.
18    #[serde(rename = "isUTC")]
19    pub is_utc: Option<bool>,
20    /// Day of the data.
21    pub day: Option<String>,
22    /// Open price.
23    pub open: Option<f64>,
24    /// Close price.
25    pub close: Option<f64>,
26    /// Open trades.
27    #[serde(rename = "openTrades")]
28    pub open_trades: Option<Vec<CryptoOpenCloseTrade>>,
29    /// Close trades.
30    #[serde(rename = "closingTrades")]
31    pub closing_trades: Option<Vec<CryptoOpenCloseTrade>>,
32}
33
34/// A single trade within a crypto daily open/close response.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[non_exhaustive]
37pub struct CryptoOpenCloseTrade {
38    /// Price of the trade.
39    #[serde(rename = "p")]
40    pub price: Option<f64>,
41    /// Size of the trade.
42    #[serde(rename = "s")]
43    pub size: Option<f64>,
44    /// Exchange.
45    #[serde(rename = "x")]
46    pub exchange: Option<i32>,
47    /// Conditions.
48    pub conditions: Option<Vec<i32>>,
49    /// Timestamp.
50    #[serde(rename = "t")]
51    pub timestamp: Option<i64>,
52}
53
54/// Fetch aggregate bars (OHLCV) for a crypto ticker over a date range.
55///
56/// # Arguments
57///
58/// * `ticker` - Crypto ticker symbol with `X:` prefix (e.g., `"X:BTCUSD"`)
59/// * `multiplier` - Size of the timespan multiplier (e.g., `1`, `5`, `15`)
60/// * `timespan` - Timespan unit (e.g., `Timespan::Day`)
61/// * `from` - Start date as `"YYYY-MM-DD"` or millisecond timestamp string
62/// * `to` - End date as `"YYYY-MM-DD"` or millisecond timestamp string
63/// * `params` - Optional parameters (adjusted, sort, limit)
64pub async fn crypto_aggregates(
65    ticker: &str,
66    multiplier: u32,
67    timespan: Timespan,
68    from: &str,
69    to: &str,
70    params: Option<AggregateParams>,
71) -> Result<AggregateResponse> {
72    let client = build_client()?;
73    let path = format!(
74        "/v2/aggs/ticker/{}/range/{}/{}/{}/{}",
75        ticker,
76        multiplier,
77        timespan.as_str(),
78        from,
79        to
80    );
81
82    let mut query_params: Vec<(&str, String)> = Vec::new();
83    if let Some(ref p) = params {
84        if let Some(adjusted) = p.adjusted {
85            query_params.push(("adjusted", adjusted.to_string()));
86        }
87        if let Some(sort) = p.sort {
88            query_params.push(("sort", sort.as_str().to_string()));
89        }
90        if let Some(limit) = p.limit {
91            query_params.push(("limit", limit.to_string()));
92        }
93    }
94
95    let query_refs: Vec<(&str, &str)> =
96        query_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
97
98    let json = client.get_raw(&path, &query_refs).await?;
99    serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
100        field: "crypto_aggregates".to_string(),
101        context: format!("Failed to parse crypto aggregate response: {e}"),
102    })
103}
104
105/// Fetch the previous day's OHLCV bar for a crypto ticker.
106///
107/// * `ticker` - Crypto ticker symbol with `X:` prefix (e.g., `"X:BTCUSD"`)
108/// * `adjusted` - Whether results are adjusted (default: true)
109pub async fn crypto_previous_close(
110    ticker: &str,
111    adjusted: Option<bool>,
112) -> Result<AggregateResponse> {
113    let client = build_client()?;
114    let path = format!("/v2/aggs/ticker/{}/prev", encode_path_segment(ticker));
115
116    let adj_str = adjusted.unwrap_or(true).to_string();
117    let params = [("adjusted", adj_str.as_str())];
118
119    let json = client.get_raw(&path, &params).await?;
120    serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
121        field: "crypto_previous_close".to_string(),
122        context: format!("Failed to parse crypto previous close response: {e}"),
123    })
124}
125
126/// Fetch grouped daily bars for the entire crypto market on a given date.
127///
128/// * `date` - Date as `"YYYY-MM-DD"`
129/// * `adjusted` - Whether results are adjusted (default: true)
130pub async fn crypto_grouped_daily(date: &str, adjusted: Option<bool>) -> Result<AggregateResponse> {
131    let client = build_client()?;
132    let path = format!(
133        "/v2/aggs/grouped/locale/global/market/crypto/{}",
134        encode_path_segment(date)
135    );
136
137    let adj_str = adjusted.unwrap_or(true).to_string();
138    let params = [("adjusted", adj_str.as_str())];
139
140    let json = client.get_raw(&path, &params).await?;
141    serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
142        field: "crypto_grouped_daily".to_string(),
143        context: format!("Failed to parse crypto grouped daily response: {e}"),
144    })
145}
146
147/// Fetch daily open/close for a crypto pair on a specific date.
148///
149/// * `from` - The "from" symbol of the pair (e.g., `"BTC"`)
150/// * `to` - The "to" symbol of the pair (e.g., `"USD"`)
151/// * `date` - Date as `"YYYY-MM-DD"`
152pub async fn crypto_daily_open_close(
153    from: &str,
154    to: &str,
155    date: &str,
156) -> Result<CryptoDailyOpenClose> {
157    let client = build_client()?;
158    let path = format!(
159        "/v1/open-close/crypto/{}/{}/{}",
160        encode_path_segment(from),
161        encode_path_segment(to),
162        encode_path_segment(date)
163    );
164
165    let json = client.get_raw(&path, &[]).await?;
166    serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
167        field: "crypto_daily_open_close".to_string(),
168        context: format!("Failed to parse crypto daily open/close response: {e}"),
169    })
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[tokio::test]
177    async fn test_crypto_aggregates_mock() {
178        let mut server = mockito::Server::new_async().await;
179        let _mock = server
180            .mock(
181                "GET",
182                "/v2/aggs/ticker/X:BTCUSD/range/1/day/2024-01-01/2024-01-31",
183            )
184            .match_query(mockito::Matcher::AllOf(vec![
185                mockito::Matcher::UrlEncoded("apiKey".into(), "test-key".into()),
186            ]))
187            .with_status(200)
188            .with_header("content-type", "application/json")
189            .with_body(
190                serde_json::json!({
191                    "ticker": "X:BTCUSD",
192                    "status": "OK",
193                    "adjusted": true,
194                    "queryCount": 1,
195                    "resultsCount": 2,
196                    "request_id": "abc123",
197                    "results": [
198                        { "o": 42000.0, "h": 43500.0, "l": 41800.0, "c": 43100.0, "v": 12345.67, "vw": 42750.0, "t": 1704067200000_i64, "n": 150000 },
199                        { "o": 43100.0, "h": 44000.0, "l": 42900.0, "c": 43800.0, "v": 11000.50, "vw": 43400.0, "t": 1704153600000_i64, "n": 140000 }
200                    ]
201                })
202                .to_string(),
203            )
204            .create_async()
205            .await;
206
207        let client = super::super::super::build_test_client(&server.url()).unwrap();
208        let json = client
209            .get_raw(
210                "/v2/aggs/ticker/X:BTCUSD/range/1/day/2024-01-01/2024-01-31",
211                &[],
212            )
213            .await
214            .unwrap();
215
216        let resp: AggregateResponse = serde_json::from_value(json).unwrap();
217        assert_eq!(resp.ticker.as_deref(), Some("X:BTCUSD"));
218        let results = resp.results.unwrap();
219        assert_eq!(results.len(), 2);
220        assert!((results[0].open - 42000.0).abs() < 0.01);
221        assert!((results[0].close - 43100.0).abs() < 0.01);
222        assert_eq!(results[0].timestamp, 1704067200000);
223    }
224}