Skip to main content

finance_query/adapters/fmp/
fund_holdings.rs

1//! Fund holdings endpoints: ETF sector weightings, country weightings, and holdings.
2
3use serde::{Deserialize, Serialize};
4
5use crate::adapters::common::encode_path_segment;
6use crate::error::Result;
7
8use super::build_client;
9
10// ============================================================================
11// Response types
12// ============================================================================
13
14/// ETF sector weighting entry.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct EtfSectorWeighting {
18    /// Sector name.
19    pub sector: Option<String>,
20    /// Weight percentage (e.g., "7.23%").
21    #[serde(rename = "weightPercentage")]
22    pub weight_percentage: Option<String>,
23}
24
25/// ETF country weighting entry.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[non_exhaustive]
28pub struct EtfCountryWeighting {
29    /// Country name.
30    pub country: Option<String>,
31    /// Weight percentage (e.g., "62.15%").
32    #[serde(rename = "weightPercentage")]
33    pub weight_percentage: Option<String>,
34}
35
36/// ETF holding entry.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[non_exhaustive]
39pub struct EtfHolding {
40    /// Asset name / ticker.
41    pub asset: Option<String>,
42    /// Number of shares held.
43    #[serde(rename = "sharesNumber")]
44    pub shares_number: Option<f64>,
45    /// Weight in ETF as a percentage.
46    #[serde(rename = "weightPercentage")]
47    pub weight_percentage: Option<f64>,
48    /// Market value.
49    #[serde(rename = "marketValue")]
50    pub market_value: Option<f64>,
51    /// Updated date.
52    pub updated: Option<String>,
53}
54
55// ============================================================================
56// Public API
57// ============================================================================
58
59/// Fetch ETF sector weightings.
60pub async fn etf_sector_weightings(symbol: &str) -> Result<Vec<EtfSectorWeighting>> {
61    let client = build_client()?;
62    let path = format!(
63        "/api/v3/etf-sector-weightings/{}",
64        encode_path_segment(symbol)
65    );
66    client.get(&path, &[]).await
67}
68
69/// Fetch ETF country weightings.
70pub async fn etf_country_weightings(symbol: &str) -> Result<Vec<EtfCountryWeighting>> {
71    let client = build_client()?;
72    let path = format!(
73        "/api/v3/etf-country-weightings/{}",
74        encode_path_segment(symbol)
75    );
76    client.get(&path, &[]).await
77}
78
79/// Fetch ETF holdings (same endpoint as ETF holder).
80pub async fn etf_holdings(symbol: &str) -> Result<Vec<EtfHolding>> {
81    let client = build_client()?;
82    let path = format!("/api/v3/etf-holder/{}", encode_path_segment(symbol));
83    client.get(&path, &[]).await
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[tokio::test]
91    async fn test_etf_sector_weightings_mock() {
92        let mut server = mockito::Server::new_async().await;
93        let _mock = server
94            .mock("GET", "/api/v3/etf-sector-weightings/SPY")
95            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
96                "apikey".into(),
97                "test-key".into(),
98            )]))
99            .with_status(200)
100            .with_body(
101                serde_json::json!([
102                    {
103                        "sector": "Technology",
104                        "weightPercentage": "29.50%"
105                    },
106                    {
107                        "sector": "Healthcare",
108                        "weightPercentage": "13.20%"
109                    }
110                ])
111                .to_string(),
112            )
113            .create_async()
114            .await;
115
116        let client = super::super::build_test_client(&server.url()).unwrap();
117        let resp: Vec<EtfSectorWeighting> = client
118            .get("/api/v3/etf-sector-weightings/SPY", &[])
119            .await
120            .unwrap();
121        assert_eq!(resp.len(), 2);
122        assert_eq!(resp[0].sector.as_deref(), Some("Technology"));
123        assert_eq!(resp[0].weight_percentage.as_deref(), Some("29.50%"));
124    }
125
126    #[tokio::test]
127    async fn test_etf_country_weightings_mock() {
128        let mut server = mockito::Server::new_async().await;
129        let _mock = server
130            .mock("GET", "/api/v3/etf-country-weightings/VEU")
131            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
132                "apikey".into(),
133                "test-key".into(),
134            )]))
135            .with_status(200)
136            .with_body(
137                serde_json::json!([
138                    {
139                        "country": "Japan",
140                        "weightPercentage": "15.80%"
141                    }
142                ])
143                .to_string(),
144            )
145            .create_async()
146            .await;
147
148        let client = super::super::build_test_client(&server.url()).unwrap();
149        let resp: Vec<EtfCountryWeighting> = client
150            .get("/api/v3/etf-country-weightings/VEU", &[])
151            .await
152            .unwrap();
153        assert_eq!(resp.len(), 1);
154        assert_eq!(resp[0].country.as_deref(), Some("Japan"));
155    }
156}