Skip to main content

finlight_client/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// One exchange listing of a company.
5#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
6#[serde(rename_all = "camelCase")]
7pub struct Listing {
8    /// Ticker symbol on this exchange.
9    pub ticker: String,
10    /// Exchange code, e.g. `XNAS`.
11    pub exchange_code: String,
12    /// Country of the exchange.
13    pub exchange_country: String,
14}
15
16/// An entity recognized in an article.
17#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
18#[serde(rename_all = "camelCase")]
19pub struct Company {
20    /// finlight's internal company id.
21    pub company_id: i64,
22    /// Recognition confidence in `[0, 1]`. The API delivers this both as a
23    /// number and as a string; both are accepted.
24    #[serde(
25        default,
26        with = "flex::opt_f64",
27        skip_serializing_if = "Option::is_none"
28    )]
29    pub confidence: Option<f64>,
30    /// Country of the company.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub country: Option<String>,
33    /// Primary exchange.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub exchange: Option<String>,
36    /// Industry classification.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub industry: Option<String>,
39    /// Sector classification.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub sector: Option<String>,
42    /// Company name.
43    pub name: String,
44    /// Primary ticker symbol.
45    pub ticker: String,
46    /// Primary ISIN.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub isin: Option<String>,
49    /// OpenFIGI identifier.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub openfigi: Option<String>,
52    /// Primary exchange listing.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub primary_listing: Option<Listing>,
55    /// All known ISINs.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub isins: Vec<String>,
58    /// Listings on other exchanges.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub other_listings: Vec<Listing>,
61}
62
63/// An enriched news article as returned by the REST API, the enhanced
64/// WebSocket stream, and webhooks.
65#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
66#[serde(rename_all = "camelCase")]
67pub struct Article {
68    /// Canonical URL of the article.
69    pub link: String,
70    /// Headline.
71    pub title: String,
72    /// Publication timestamp. The API's timestamp formats (RFC 3339 with or
73    /// without zone, space-separated, date-only) are all accepted.
74    #[serde(with = "flex::datetime")]
75    pub publish_date: DateTime<Utc>,
76    /// Source domain, e.g. `www.reuters.com`.
77    pub source: String,
78    /// ISO 639-1 language code.
79    pub language: String,
80    /// Article summary.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub summary: Option<String>,
83    /// Image URLs.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub images: Vec<String>,
86    /// When finlight first stored the article.
87    #[serde(
88        default,
89        with = "flex::opt_datetime",
90        skip_serializing_if = "Option::is_none"
91    )]
92    pub created_at: Option<DateTime<Utc>>,
93    /// When the article was last revised by its publisher.
94    #[serde(
95        default,
96        with = "flex::opt_datetime",
97        skip_serializing_if = "Option::is_none"
98    )]
99    pub revised_date: Option<DateTime<Utc>>,
100    /// Whether this delivery is an update to a previously seen article.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub is_update: Option<bool>,
103    /// Categories assigned by finlight's classification.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub categories: Vec<String>,
106    /// Sentiment label (`positive`, `negative`, `neutral`).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub sentiment: Option<String>,
109    /// Sentiment confidence in `[0, 1]`; accepted both as number and string.
110    #[serde(
111        default,
112        with = "flex::opt_f64",
113        skip_serializing_if = "Option::is_none"
114    )]
115    pub confidence: Option<f64>,
116    /// Full article content (when requested and available).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub content: Option<String>,
119    /// Companies recognized in the article (when requested).
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub companies: Vec<Company>,
122    /// Countries the article relates to.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub countries: Vec<String>,
125}
126
127/// An unenriched article as delivered by the raw WebSocket stream (no
128/// sentiment, entities, or content — lower latency).
129#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct RawArticle {
132    /// Canonical URL of the article.
133    pub link: String,
134    /// Headline.
135    pub title: String,
136    /// Publication timestamp.
137    #[serde(with = "flex::datetime")]
138    pub publish_date: DateTime<Utc>,
139    /// Source domain.
140    pub source: String,
141    /// ISO 639-1 language code.
142    pub language: String,
143    /// Article summary.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub summary: Option<String>,
146    /// Image URLs.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub images: Vec<String>,
149    /// When finlight first stored the article.
150    #[serde(
151        default,
152        with = "flex::opt_datetime",
153        skip_serializing_if = "Option::is_none"
154    )]
155    pub created_at: Option<DateTime<Utc>>,
156    /// When the article was last revised by its publisher.
157    #[serde(
158        default,
159        with = "flex::opt_datetime",
160        skip_serializing_if = "Option::is_none"
161    )]
162    pub revised_date: Option<DateTime<Utc>>,
163    /// Whether this delivery is an update to a previously seen article.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub is_update: Option<bool>,
166    /// Categories assigned by finlight's classification.
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub categories: Vec<String>,
169}
170
171/// Paginated result of [`ArticleService::fetch_articles`](crate::ArticleService::fetch_articles).
172#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
173#[serde(rename_all = "camelCase")]
174pub struct ArticleResponse {
175    /// Request status reported by the server.
176    pub status: String,
177    /// Result page number.
178    pub page: u32,
179    /// Page size used for this result.
180    pub page_size: u32,
181    /// The articles of this page.
182    pub articles: Vec<Article>,
183}
184
185/// A news source available through the API.
186#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
187#[serde(rename_all = "camelCase")]
188pub struct Source {
189    /// Source domain, e.g. `www.reuters.com`.
190    pub domain: String,
191    /// Whether full article content is available for this source.
192    pub is_content_available: bool,
193    /// Whether the source is queried by default (without opt-in).
194    pub is_default_source: bool,
195}
196
197/// Serde adapters for the flexible value representations used by the API:
198/// confidence as number or string, timestamps in several formats.
199pub(crate) mod flex {
200    use chrono::{DateTime, NaiveDate, NaiveDateTime, SecondsFormat, Utc};
201    use serde::de::{Deserializer, Error as _};
202    use serde::{Deserialize, Serializer};
203
204    #[derive(Deserialize)]
205    #[serde(untagged)]
206    enum NumOrStr {
207        Num(f64),
208        Str(String),
209    }
210
211    fn num_or_str_to_f64<E: serde::de::Error>(v: NumOrStr) -> Result<f64, E> {
212        match v {
213            NumOrStr::Num(n) => Ok(n),
214            NumOrStr::Str(s) => s
215                .trim()
216                .parse::<f64>()
217                .map_err(|_| E::custom(format!("cannot parse {s:?} as float"))),
218        }
219    }
220
221    /// `Option<f64>` from a JSON number, string, or null.
222    pub(crate) mod opt_f64 {
223        use super::*;
224
225        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
226            de: D,
227        ) -> Result<Option<f64>, D::Error> {
228            Option::<NumOrStr>::deserialize(de)?
229                .map(num_or_str_to_f64)
230                .transpose()
231        }
232
233        pub(crate) fn serialize<S: Serializer>(v: &Option<f64>, ser: S) -> Result<S::Ok, S::Error> {
234            match v {
235                Some(f) => ser.serialize_f64(*f),
236                None => ser.serialize_none(),
237            }
238        }
239    }
240
241    pub(crate) fn parse_datetime(s: &str) -> Option<DateTime<Utc>> {
242        let s = s.trim();
243        if let Ok(t) = DateTime::parse_from_rfc3339(s) {
244            return Some(t.with_timezone(&Utc));
245        }
246        for format in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] {
247            if let Ok(t) = NaiveDateTime::parse_from_str(s, format) {
248                return Some(t.and_utc());
249            }
250        }
251        if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
252            return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
253        }
254        None
255    }
256
257    /// `DateTime<Utc>` from the timestamp formats used by the finlight API
258    /// (RFC 3339 with or without zone, space-separated, date-only).
259    pub(crate) mod datetime {
260        use super::*;
261
262        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
263            de: D,
264        ) -> Result<DateTime<Utc>, D::Error> {
265            let s = String::deserialize(de)?;
266            parse_datetime(&s)
267                .ok_or_else(|| D::Error::custom(format!("cannot parse {s:?} as timestamp")))
268        }
269
270        pub(crate) fn serialize<S: Serializer>(
271            t: &DateTime<Utc>,
272            ser: S,
273        ) -> Result<S::Ok, S::Error> {
274            ser.serialize_str(&t.to_rfc3339_opts(SecondsFormat::AutoSi, true))
275        }
276    }
277
278    /// `Option<DateTime<Utc>>` variant of [`datetime`].
279    pub(crate) mod opt_datetime {
280        use super::*;
281
282        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
283            de: D,
284        ) -> Result<Option<DateTime<Utc>>, D::Error> {
285            Option::<String>::deserialize(de)?
286                .map(|s| {
287                    parse_datetime(&s)
288                        .ok_or_else(|| D::Error::custom(format!("cannot parse {s:?} as timestamp")))
289                })
290                .transpose()
291        }
292
293        pub(crate) fn serialize<S: Serializer>(
294            t: &Option<DateTime<Utc>>,
295            ser: S,
296        ) -> Result<S::Ok, S::Error> {
297            match t {
298                Some(t) => datetime::serialize(t, ser),
299                None => ser.serialize_none(),
300            }
301        }
302    }
303}