finance_query_core/models/
holders.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum HolderType {
8    #[serde(rename = "major")]
9    Major,
10    #[serde(rename = "institutional")]
11    Institutional,
12    #[serde(rename = "mutualfund")]
13    MutualFund,
14    #[serde(rename = "insider_transactions")]
15    InsiderTransactions,
16    #[serde(rename = "insider_purchases")]
17    InsiderPurchases,
18    #[serde(rename = "insider_roster")]
19    InsiderRoster,
20}
21
22impl HolderType {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            HolderType::Major => "major",
26            HolderType::Institutional => "institutional",
27            HolderType::MutualFund => "mutualfund",
28            HolderType::InsiderTransactions => "insider_transactions",
29            HolderType::InsiderPurchases => "insider_purchases",
30            HolderType::InsiderRoster => "insider_roster",
31        }
32    }
33
34    #[allow(dead_code)]
35    pub fn from_str(s: &str) -> Option<Self> {
36        match s {
37            "major" => Some(HolderType::Major),
38            "institutional" => Some(HolderType::Institutional),
39            "mutualfund" => Some(HolderType::MutualFund),
40            "insider_transactions" => Some(HolderType::InsiderTransactions),
41            "insider_purchases" => Some(HolderType::InsiderPurchases),
42            "insider_roster" => Some(HolderType::InsiderRoster),
43            _ => None,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MajorHoldersBreakdown {
50    #[serde(rename = "breakdownData")]
51    pub breakdown_data: HashMap<String, serde_json::Value>,
52}
53
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct InstitutionalHolder {
57    pub holder: String,
58    pub shares: i64,
59    #[serde(rename = "dateReported")]
60    pub date_reported: DateTime<Utc>,
61    #[serde(skip_serializing_if = "Option::is_none", rename = "percentOut")]
62    pub percent_out: Option<f64>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub value: Option<i64>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct MutualFundHolder {
69    pub holder: String,
70    pub shares: i64,
71    #[serde(rename = "dateReported")]
72    pub date_reported: DateTime<Utc>,
73    #[serde(skip_serializing_if = "Option::is_none", rename = "percentOut")]
74    pub percent_out: Option<f64>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub value: Option<i64>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct InsiderTransaction {
81    #[serde(rename = "startDate")]
82    pub start_date: DateTime<Utc>,
83    pub insider: String,
84    pub position: String,
85    pub transaction: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub shares: Option<i64>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub value: Option<i64>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub ownership: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct InsiderPurchase {
96    pub period: String,
97    #[serde(skip_serializing_if = "Option::is_none", rename = "purchasesShares")]
98    pub purchases_shares: Option<i64>,
99    #[serde(skip_serializing_if = "Option::is_none", rename = "purchasesTransactions")]
100    pub purchases_transactions: Option<i64>,
101    #[serde(skip_serializing_if = "Option::is_none", rename = "salesShares")]
102    pub sales_shares: Option<i64>,
103    #[serde(skip_serializing_if = "Option::is_none", rename = "salesTransactions")]
104    pub sales_transactions: Option<i64>,
105    #[serde(skip_serializing_if = "Option::is_none", rename = "netShares")]
106    pub net_shares: Option<i64>,
107    #[serde(skip_serializing_if = "Option::is_none", rename = "netTransactions")]
108    pub net_transactions: Option<i64>,
109    #[serde(skip_serializing_if = "Option::is_none", rename = "totalInsiderShares")]
110    pub total_insider_shares: Option<i64>,
111    #[serde(skip_serializing_if = "Option::is_none", rename = "netPercentInsiderShares")]
112    pub net_percent_insider_shares: Option<f64>,
113    #[serde(skip_serializing_if = "Option::is_none", rename = "buyPercentInsiderShares")]
114    pub buy_percent_insider_shares: Option<f64>,
115    #[serde(skip_serializing_if = "Option::is_none", rename = "sellPercentInsiderShares")]
116    pub sell_percent_insider_shares: Option<f64>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct InsiderRosterMember {
121    pub name: String,
122    pub position: String,
123    #[serde(skip_serializing_if = "Option::is_none", rename = "mostRecentTransaction")]
124    pub most_recent_transaction: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none", rename = "latestTransactionDate")]
126    pub latest_transaction_date: Option<DateTime<Utc>>,
127    #[serde(skip_serializing_if = "Option::is_none", rename = "sharesOwnedDirectly")]
128    pub shares_owned_directly: Option<i64>,
129    #[serde(skip_serializing_if = "Option::is_none", rename = "sharesOwnedIndirectly")]
130    pub shares_owned_indirectly: Option<i64>,
131    #[serde(skip_serializing_if = "Option::is_none", rename = "positionDirectDate")]
132    pub position_direct_date: Option<DateTime<Utc>>,
133}
134
135// Response types
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct MajorHoldersResponse {
138    pub symbol: String,
139    pub breakdown: MajorHoldersBreakdown,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct InstitutionalHoldersResponse {
144    pub symbol: String,
145    pub holders: Vec<InstitutionalHolder>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct MutualFundHoldersResponse {
150    pub symbol: String,
151    pub holders: Vec<MutualFundHolder>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct InsiderTransactionsResponse {
156    pub symbol: String,
157    pub transactions: Vec<InsiderTransaction>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct InsiderPurchasesResponse {
162    pub symbol: String,
163    pub summary: InsiderPurchase,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct InsiderRosterResponse {
168    pub symbol: String,
169    pub roster: Vec<InsiderRosterMember>,
170}
171
172// Internal data structure used by service layer
173#[derive(Debug, Clone)]
174pub struct HoldersData {
175    pub symbol: String,
176    #[allow(dead_code)]
177    pub holder_type: HolderType,
178    pub major_breakdown: Option<MajorHoldersBreakdown>,
179    pub institutional_holders: Option<Vec<InstitutionalHolder>>,
180    pub mutualfund_holders: Option<Vec<MutualFundHolder>>,
181    pub insider_transactions: Option<Vec<InsiderTransaction>>,
182    pub insider_purchases: Option<InsiderPurchase>,
183    pub insider_roster: Option<Vec<InsiderRosterMember>>,
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use proptest::prelude::*;
190
191    fn optional_f64() -> impl Strategy<Value = Option<f64>> {
192        proptest::option::of(0.0f64..100.0f64)
193    }
194
195    fn optional_i64() -> impl Strategy<Value = Option<i64>> {
196        proptest::option::of(0i64..1_000_000_000i64)
197    }
198
199    // **Feature: crate-extraction, Property 1: Model Serialization Round-Trip**
200    // **Validates: Requirements 2.2**
201    proptest! {
202        #![proptest_config(ProptestConfig::with_cases(100))]
203
204        #[test]
205        fn holder_type_roundtrip(ht in prop_oneof![
206            Just(HolderType::Major),
207            Just(HolderType::Institutional),
208            Just(HolderType::MutualFund),
209            Just(HolderType::InsiderTransactions),
210            Just(HolderType::InsiderPurchases),
211            Just(HolderType::InsiderRoster),
212        ]) {
213            let json = serde_json::to_string(&ht).unwrap();
214            let parsed: HolderType = serde_json::from_str(&json).unwrap();
215
216            prop_assert_eq!(ht.as_str(), parsed.as_str());
217        }
218
219        #[test]
220        fn institutional_holder_roundtrip(
221            holder in "[A-Za-z ]{1,50}",
222            shares in 0i64..1_000_000_000i64,
223            percent_out in optional_f64(),
224            value in optional_i64(),
225        ) {
226            let ih = InstitutionalHolder {
227                holder: holder.clone(),
228                shares,
229                date_reported: Utc::now(),
230                percent_out,
231                value,
232            };
233
234            let json = serde_json::to_string(&ih).unwrap();
235            let parsed: InstitutionalHolder = serde_json::from_str(&json).unwrap();
236
237            prop_assert_eq!(ih.holder, parsed.holder);
238            prop_assert_eq!(ih.shares, parsed.shares);
239            prop_assert_eq!(ih.value, parsed.value);
240        }
241
242        #[test]
243        fn insider_purchase_roundtrip(
244            period in "[A-Za-z0-9 ]{1,20}",
245            purchases_shares in optional_i64(),
246            sales_shares in optional_i64(),
247        ) {
248            let ip = InsiderPurchase {
249                period: period.clone(),
250                purchases_shares,
251                purchases_transactions: None,
252                sales_shares,
253                sales_transactions: None,
254                net_shares: None,
255                net_transactions: None,
256                total_insider_shares: None,
257                net_percent_insider_shares: None,
258                buy_percent_insider_shares: None,
259                sell_percent_insider_shares: None,
260            };
261
262            let json = serde_json::to_string(&ip).unwrap();
263            let parsed: InsiderPurchase = serde_json::from_str(&json).unwrap();
264
265            prop_assert_eq!(ip.period, parsed.period);
266            prop_assert_eq!(ip.purchases_shares, parsed.purchases_shares);
267            prop_assert_eq!(ip.sales_shares, parsed.sales_shares);
268        }
269    }
270}