Skip to main content

paft_fundamentals/
profile.rs

1//! Profile-related types under `paft_fundamentals::profile`.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::str::FromStr;
5
6use chrono::NaiveDate;
7#[cfg(feature = "dataframe")]
8use df_derive_macros::ToDataFrame;
9use paft_domain::Isin;
10#[cfg(feature = "dataframe")]
11use paft_utils::dataframe::{Columnar, ToDataFrame, ToDataFrameVec};
12
13use crate::FundamentalsError;
14
15paft_core::other_string_code_type!(
16    /// Provider-specific fund kind not modeled by [`FundKind`].
17    pub struct OtherFundKind for FundKind;
18    type Error = FundamentalsError;
19    parse(input) => FundKind::from_str(input);
20    invalid(input) => FundamentalsError::InvalidEnumValue {
21        enum_name: "FundKind",
22        value: input.to_string(),
23    };
24);
25
26/// Fund types with canonical variants and extensible fallback.
27///
28/// This enum provides type-safe handling of fund types while gracefully
29/// handling unknown or provider-specific fund types through the `Other` variant.
30///
31/// Canonical/serde rules:
32/// - Emission uses a single canonical form per variant (UPPERCASE ASCII, no spaces)
33/// - Parser accepts a superset of tokens (aliases, case-insensitive)
34/// - `Other(s)` serializes to its canonical `code()` string (no escape prefix)
35/// - `Display` output matches the canonical code for known variants and the raw `s` for `Other(s)`
36/// - Serde round-trips preserve identity for canonical variants; unknown tokens normalize to `Other(UPPERCASE)`
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38#[non_exhaustive]
39pub enum FundKind {
40    /// Exchange-Traded Fund
41    Etf,
42    /// Mutual Fund
43    MutualFund,
44    /// Index Fund
45    IndexFund,
46    /// Closed-End Fund
47    ClosedEndFund,
48    /// Money Market Fund
49    MoneyMarketFund,
50    /// Hedge Fund
51    HedgeFund,
52    /// Real Estate Investment Trust
53    Reit,
54    /// Unit Investment Trust
55    UnitInvestmentTrust,
56    /// Unknown or provider-specific fund type
57    Other(OtherFundKind),
58}
59
60impl FundKind {
61    /// Attempts to parse a fund kind, uppercasing unknown inputs into `Other`.
62    ///
63    /// # Errors
64    /// Returns `FundamentalsError::InvalidEnumValue` when `input` is empty/whitespace.
65    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
66    pub fn try_from_str(input: &str) -> Result<Self, FundamentalsError> {
67        Self::from_str(input)
68    }
69
70    /// Builds an unknown fund kind, rejecting modeled fund kinds and aliases.
71    ///
72    /// # Errors
73    /// Returns an error if `input` is empty, cannot be canonicalized, or parses
74    /// to a modeled [`FundKind`] variant.
75    pub fn other(input: &str) -> Result<Self, FundamentalsError> {
76        OtherFundKind::new(input).map(Self::Other)
77    }
78}
79
80// Centralized string impls via macro
81paft_core::string_enum_with_code!(
82    FundKind, Other(OtherFundKind), "FundKind",
83    type Error = FundamentalsError;
84    invalid(input) => FundamentalsError::InvalidEnumValue {
85        enum_name: "FundKind",
86        value: input.to_string(),
87    };
88    {
89        "ETF" => FundKind::Etf,
90        "MUTUAL_FUND" => FundKind::MutualFund,
91        "INDEX_FUND" => FundKind::IndexFund,
92        "CLOSED_END_FUND" => FundKind::ClosedEndFund,
93        "MONEY_MARKET_FUND" => FundKind::MoneyMarketFund,
94        "HEDGE_FUND" => FundKind::HedgeFund,
95        "REIT" => FundKind::Reit,
96        "UIT" => FundKind::UnitInvestmentTrust
97    },
98    {
99        "EXCHANGE_TRADED_FUND" => FundKind::Etf,
100        "MUTUAL" => FundKind::MutualFund,
101        "INDEX" => FundKind::IndexFund,
102        "CEF" => FundKind::ClosedEndFund,
103        "MMF" => FundKind::MoneyMarketFund,
104        "REAL_ESTATE_INVESTMENT_TRUST" => FundKind::Reit,
105        "UNIT_INVESTMENT_TRUST" => FundKind::UnitInvestmentTrust
106    }
107);
108
109paft_core::impl_display_via_code!(FundKind);
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
112#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
113/// Postal address details.
114pub struct Address {
115    /// First address line.
116    pub street1: Option<String>,
117    /// Second address line.
118    pub street2: Option<String>,
119    /// City or locality.
120    pub city: Option<String>,
121    /// State or region.
122    pub state: Option<String>,
123    /// Country.
124    pub country: Option<String>,
125    /// Postal or ZIP code.
126    pub zip: Option<String>,
127}
128
129/// Company profile details (provider-agnostic; maps well to common models).
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
132pub struct CompanyProfile {
133    /// Company display name.
134    pub name: String,
135    /// Sector classification.
136    pub sector: Option<String>,
137    /// Industry classification.
138    pub industry: Option<String>,
139    /// Company website.
140    pub website: Option<String>,
141    /// Registered address.
142    pub address: Option<Address>,
143    /// Business summary.
144    pub summary: Option<String>,
145    /// International Securities Identification Number.
146    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
147    pub isin: Option<Isin>,
148}
149
150/// Fund profile details.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
153pub struct FundProfile {
154    /// Fund name.
155    pub name: String,
156    /// Fund family (e.g., Vanguard, iShares).
157    pub family: Option<String>,
158    /// Fund type with canonical variants and extensible fallback.
159    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
160    pub kind: FundKind,
161    /// International Securities Identification Number.
162    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
163    pub isin: Option<Isin>,
164}
165
166/// Union of supported profile kinds.
167///
168/// Serde uses a flat tagged shape with a `kind` discriminator. Fund profiles
169/// use `fund_kind` on the wire to avoid colliding with the discriminator.
170/// This is a tagged data payload, not a strict semantic metadata shape:
171/// deserialization intentionally ignores unmodeled provider fields.
172#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum Profile {
175    /// Company profile.
176    Company(CompanyProfile),
177    /// Fund profile.
178    Fund(FundProfile),
179}
180
181#[derive(Serialize, Deserialize)]
182#[serde(tag = "kind", rename_all = "snake_case")]
183enum ProfileWire {
184    Company {
185        name: String,
186        sector: Option<String>,
187        industry: Option<String>,
188        website: Option<String>,
189        address: Option<Address>,
190        summary: Option<String>,
191        isin: Option<Isin>,
192    },
193    Fund {
194        name: String,
195        family: Option<String>,
196        fund_kind: FundKind,
197        isin: Option<Isin>,
198    },
199}
200
201impl From<&Profile> for ProfileWire {
202    fn from(profile: &Profile) -> Self {
203        match profile {
204            Profile::Company(company) => Self::Company {
205                name: company.name.clone(),
206                sector: company.sector.clone(),
207                industry: company.industry.clone(),
208                website: company.website.clone(),
209                address: company.address.clone(),
210                summary: company.summary.clone(),
211                isin: company.isin.clone(),
212            },
213            Profile::Fund(fund) => Self::Fund {
214                name: fund.name.clone(),
215                family: fund.family.clone(),
216                fund_kind: fund.kind.clone(),
217                isin: fund.isin.clone(),
218            },
219        }
220    }
221}
222
223impl From<ProfileWire> for Profile {
224    fn from(wire: ProfileWire) -> Self {
225        match wire {
226            ProfileWire::Company {
227                name,
228                sector,
229                industry,
230                website,
231                address,
232                summary,
233                isin,
234            } => Self::Company(CompanyProfile {
235                name,
236                sector,
237                industry,
238                website,
239                address,
240                summary,
241                isin,
242            }),
243            ProfileWire::Fund {
244                name,
245                family,
246                fund_kind,
247                isin,
248            } => Self::Fund(FundProfile {
249                name,
250                family,
251                kind: fund_kind,
252                isin,
253            }),
254        }
255    }
256}
257
258impl Serialize for Profile {
259    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
260    where
261        S: Serializer,
262    {
263        ProfileWire::from(self).serialize(serializer)
264    }
265}
266
267impl<'de> Deserialize<'de> for Profile {
268    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
269    where
270        D: Deserializer<'de>,
271    {
272        Ok(Self::from(ProfileWire::deserialize(deserializer)?))
273    }
274}
275
276impl Profile {
277    /// Returns the ISIN for the company or fund, if available.
278    #[must_use]
279    pub const fn isin(&self) -> Option<&Isin> {
280        match self {
281            Self::Company(c) => c.isin.as_ref(),
282            Self::Fund(f) => f.isin.as_ref(),
283        }
284    }
285}
286
287#[cfg(feature = "dataframe")]
288#[derive(Debug, Clone)]
289#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
290struct ProfileRow {
291    pub profile_type: String,
292    pub name: String,
293    pub sector: Option<String>,
294    pub industry: Option<String>,
295    pub website: Option<String>,
296    pub address: Option<Address>,
297    pub summary: Option<String>,
298    pub family: Option<String>,
299    pub fund_kind: Option<String>,
300    pub isin: Option<String>,
301}
302
303#[cfg(feature = "dataframe")]
304impl From<&Profile> for ProfileRow {
305    fn from(profile: &Profile) -> Self {
306        match profile {
307            Profile::Company(company) => Self {
308                profile_type: "Company".to_string(),
309                name: company.name.clone(),
310                sector: company.sector.clone(),
311                industry: company.industry.clone(),
312                website: company.website.clone(),
313                address: company.address.clone(),
314                summary: company.summary.clone(),
315                family: None,
316                fund_kind: None,
317                isin: company.isin.as_ref().map(ToString::to_string),
318            },
319            Profile::Fund(fund) => Self {
320                profile_type: "Fund".to_string(),
321                name: fund.name.clone(),
322                sector: None,
323                industry: None,
324                website: None,
325                address: None,
326                summary: None,
327                family: fund.family.clone(),
328                fund_kind: Some(fund.kind.to_string()),
329                isin: fund.isin.as_ref().map(ToString::to_string),
330            },
331        }
332    }
333}
334
335#[cfg(feature = "dataframe")]
336impl ToDataFrame for Profile {
337    fn to_dataframe(&self) -> polars::prelude::PolarsResult<polars::prelude::DataFrame> {
338        ProfileRow::from(self).to_dataframe()
339    }
340
341    fn empty_dataframe() -> polars::prelude::PolarsResult<polars::prelude::DataFrame> {
342        ProfileRow::empty_dataframe()
343    }
344
345    fn schema() -> polars::prelude::PolarsResult<Vec<(String, polars::datatypes::DataType)>> {
346        ProfileRow::schema()
347    }
348}
349
350#[cfg(feature = "dataframe")]
351impl Columnar for Profile {
352    fn columnar_from_refs(
353        items: &[&Self],
354    ) -> polars::prelude::PolarsResult<polars::prelude::DataFrame> {
355        let rows: Vec<ProfileRow> = items.iter().copied().map(ProfileRow::from).collect();
356        rows.as_slice().to_dataframe()
357    }
358}
359
360/// Represents a single data point in a time series of shares outstanding.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
363pub struct ShareCount {
364    /// The calendar date for the data point.
365    pub date: NaiveDate,
366    /// The number of shares outstanding.
367    pub shares: u64,
368}