1use super::error::MassiveError;
6use super::pagination::PaginationGuard;
7use super::rest::MassiveRestClient;
8use async_stream::try_stream;
9use chrono::{DateTime, NaiveDate, Utc};
10use futures::Stream;
11use rust_decimal::Decimal;
12use serde::Deserialize;
13use std::collections::HashMap;
14use tracing::{debug, warn};
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
22pub enum SortOrder {
23 #[default]
24 Asc,
25 Desc,
26}
27
28impl SortOrder {
29 fn as_str(self) -> &'static str {
30 match self {
31 SortOrder::Asc => "asc",
32 SortOrder::Desc => "desc",
33 }
34 }
35}
36
37impl std::fmt::Display for SortOrder {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(self.as_str())
40 }
41}
42
43#[derive(Debug, Default, Clone)]
65pub struct TickerQuery {
66 pub ticker: Option<String>,
68 pub asset_type: Option<String>,
70 pub market: Option<String>,
72 pub exchange: Option<String>,
74 pub active: Option<bool>,
76 pub search: Option<String>,
78 pub limit: Option<u16>,
80 pub order: Option<SortOrder>,
82 pub sort: Option<String>,
84 pub ticker_gte: Option<String>,
86 pub ticker_gt: Option<String>,
88 pub ticker_lte: Option<String>,
90 pub ticker_lt: Option<String>,
92}
93
94impl TickerQuery {
95 #[must_use]
97 pub fn new() -> Self {
98 Self::default()
99 }
100
101 #[must_use]
103 pub fn ticker(mut self, v: impl Into<String>) -> Self {
104 self.ticker = Some(v.into());
105 self
106 }
107
108 #[must_use]
110 pub fn asset_type(mut self, v: impl Into<String>) -> Self {
111 self.asset_type = Some(v.into());
112 self
113 }
114
115 #[must_use]
117 pub fn market(mut self, v: impl Into<String>) -> Self {
118 self.market = Some(v.into());
119 self
120 }
121
122 #[must_use]
124 pub fn exchange(mut self, v: impl Into<String>) -> Self {
125 self.exchange = Some(v.into());
126 self
127 }
128
129 #[must_use]
131 pub fn active(mut self, v: bool) -> Self {
132 self.active = Some(v);
133 self
134 }
135
136 #[must_use]
138 pub fn search(mut self, v: impl Into<String>) -> Self {
139 self.search = Some(v.into());
140 self
141 }
142
143 #[must_use]
145 pub fn limit(mut self, v: u16) -> Self {
146 self.limit = Some(v.min(1000));
147 self
148 }
149
150 #[must_use]
152 pub fn order(mut self, v: SortOrder) -> Self {
153 self.order = Some(v);
154 self
155 }
156
157 #[must_use]
159 pub fn sort(mut self, v: impl Into<String>) -> Self {
160 self.sort = Some(v.into());
161 self
162 }
163
164 #[must_use]
166 pub fn ticker_gte(mut self, v: impl Into<String>) -> Self {
167 self.ticker_gte = Some(v.into());
168 self
169 }
170
171 #[must_use]
173 pub fn ticker_gt(mut self, v: impl Into<String>) -> Self {
174 self.ticker_gt = Some(v.into());
175 self
176 }
177
178 #[must_use]
180 pub fn ticker_lte(mut self, v: impl Into<String>) -> Self {
181 self.ticker_lte = Some(v.into());
182 self
183 }
184
185 #[must_use]
187 pub fn ticker_lt(mut self, v: impl Into<String>) -> Self {
188 self.ticker_lt = Some(v.into());
189 self
190 }
191
192 fn to_query_string(&self) -> String {
194 let mut pairs: Vec<(&str, String)> = Vec::new();
195
196 if let Some(ref v) = self.ticker {
197 pairs.push(("ticker", v.clone()));
198 }
199 if let Some(ref v) = self.asset_type {
200 pairs.push(("type", v.clone()));
201 }
202 if let Some(ref v) = self.market {
203 pairs.push(("market", v.clone()));
204 }
205 if let Some(ref v) = self.exchange {
206 pairs.push(("exchange", v.clone()));
207 }
208 if let Some(v) = self.active {
209 pairs.push(("active", v.to_string()));
210 }
211 if let Some(ref v) = self.search {
212 pairs.push(("search", v.clone()));
213 }
214 if let Some(v) = self.limit {
215 pairs.push(("limit", v.to_string()));
216 }
217 if let Some(v) = self.order {
218 pairs.push(("order", v.as_str().to_string()));
219 }
220 if let Some(ref v) = self.sort {
221 pairs.push(("sort", v.clone()));
222 }
223 if let Some(ref v) = self.ticker_gte {
224 pairs.push(("ticker.gte", v.clone()));
225 }
226 if let Some(ref v) = self.ticker_gt {
227 pairs.push(("ticker.gt", v.clone()));
228 }
229 if let Some(ref v) = self.ticker_lte {
230 pairs.push(("ticker.lte", v.clone()));
231 }
232 if let Some(ref v) = self.ticker_lt {
233 pairs.push(("ticker.lt", v.clone()));
234 }
235
236 if pairs.is_empty() {
237 String::new()
238 } else {
239 let encoded: Vec<String> = pairs
240 .into_iter()
241 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
242 .collect();
243 format!("?{}", encoded.join("&"))
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq)]
254pub struct Ticker {
255 pub ticker: String,
257 pub name: String,
259 pub market: String,
261 pub asset_type: String,
263 pub active: bool,
265 pub primary_exchange: Option<String>,
267 pub currency_name: Option<String>,
269 pub composite_figi: Option<String>,
271 pub locale: Option<String>,
273 pub last_updated_utc: Option<DateTime<Utc>>,
275}
276
277#[derive(Debug, Clone, PartialEq)]
279pub struct TickerDetails {
280 pub ticker: Ticker,
282 pub description: Option<String>,
284 pub homepage_url: Option<String>,
286 pub total_employees: Option<u64>,
288 pub market_cap: Option<Decimal>,
290 pub phone_number: Option<String>,
292 pub address: Option<Address>,
294 pub sic_code: Option<String>,
296 pub sic_description: Option<String>,
298 pub ticker_root: Option<String>,
300 pub list_date: Option<NaiveDate>,
302 pub delisted_utc: Option<DateTime<Utc>>,
304 pub share_class_shares_outstanding: Option<u64>,
306 pub weighted_shares_outstanding: Option<u64>,
308 pub round_lot: Option<u32>,
310}
311
312#[derive(Debug, Clone, PartialEq, Default)]
314pub struct Address {
315 pub address1: Option<String>,
316 pub city: Option<String>,
317 pub state: Option<String>,
318 pub postal_code: Option<String>,
319}
320
321#[derive(Debug, Clone, PartialEq)]
323pub struct Exchange {
324 pub id: i64,
326 pub name: String,
328 pub acronym: Option<String>,
330 pub mic: Option<String>,
332 pub operating_mic: Option<String>,
334 pub asset_class: String,
336 pub locale: String,
338 pub exchange_type: Option<String>,
340 pub url: Option<String>,
342}
343
344#[derive(Debug, Clone, PartialEq)]
346pub struct MarketStatus {
347 pub market: String,
349 pub server_time: DateTime<Utc>,
351 pub after_hours: bool,
353 pub early_hours: bool,
355 pub currencies: CurrencyStatus,
357 pub exchanges: HashMap<String, String>,
359 pub indices_groups: HashMap<String, String>,
361}
362
363#[derive(Debug, Clone, PartialEq, Default)]
365pub struct CurrencyStatus {
366 pub crypto: String,
368 pub fx: String,
370}
371
372#[derive(Debug, Clone, PartialEq)]
374pub struct MarketHoliday {
375 pub date: NaiveDate,
377 pub exchange: String,
379 pub name: String,
381 pub status: String,
383 pub open: Option<DateTime<Utc>>,
385 pub close: Option<DateTime<Utc>>,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
391pub enum DividendFrequency {
392 Annual,
394 SemiAnnual,
396 Quarterly,
398 Monthly,
400 Unknown(u8),
402}
403
404impl DividendFrequency {
405 #[must_use]
407 pub fn from_raw(value: u8) -> Self {
408 match value {
409 1 => Self::Annual,
410 2 => Self::SemiAnnual,
411 4 => Self::Quarterly,
412 12 => Self::Monthly,
413 other => Self::Unknown(other),
414 }
415 }
416
417 #[must_use]
419 pub fn to_raw(self) -> u8 {
420 match self {
421 Self::Annual => 1,
422 Self::SemiAnnual => 2,
423 Self::Quarterly => 4,
424 Self::Monthly => 12,
425 Self::Unknown(v) => v,
426 }
427 }
428}
429
430impl std::fmt::Display for DividendFrequency {
431 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432 match self {
433 Self::Annual => write!(f, "annual"),
434 Self::SemiAnnual => write!(f, "semi-annual"),
435 Self::Quarterly => write!(f, "quarterly"),
436 Self::Monthly => write!(f, "monthly"),
437 Self::Unknown(v) => write!(f, "unknown({v})"),
438 }
439 }
440}
441
442#[derive(Debug, Clone, PartialEq)]
444pub struct Dividend {
445 pub ticker: String,
447 pub cash_amount: Decimal,
449 pub currency: String,
451 pub declaration_date: Option<NaiveDate>,
453 pub ex_dividend_date: NaiveDate,
455 pub record_date: Option<NaiveDate>,
457 pub pay_date: Option<NaiveDate>,
459 pub frequency: Option<DividendFrequency>,
461 pub dividend_type: Option<String>,
463}
464
465#[derive(Debug, Clone, PartialEq)]
467pub struct StockSplit {
468 pub ticker: String,
470 pub execution_date: NaiveDate,
472 pub split_to: Decimal,
474 pub split_from: Decimal,
476}
477
478#[derive(Debug, Default, Clone)]
484pub struct DividendQuery {
485 pub ticker: Option<String>,
487 pub ex_dividend_date: Option<NaiveDate>,
489 pub ex_dividend_date_gte: Option<NaiveDate>,
491 pub ex_dividend_date_lte: Option<NaiveDate>,
493 pub dividend_type: Option<String>,
495 pub limit: Option<u16>,
497 pub order: Option<SortOrder>,
499}
500
501impl DividendQuery {
502 #[must_use]
504 pub fn new() -> Self {
505 Self::default()
506 }
507
508 #[must_use]
510 pub fn ticker(mut self, v: impl Into<String>) -> Self {
511 self.ticker = Some(v.into());
512 self
513 }
514
515 #[must_use]
517 pub fn ex_dividend_date(mut self, v: NaiveDate) -> Self {
518 self.ex_dividend_date = Some(v);
519 self
520 }
521
522 #[must_use]
524 pub fn ex_dividend_date_gte(mut self, v: NaiveDate) -> Self {
525 self.ex_dividend_date_gte = Some(v);
526 self
527 }
528
529 #[must_use]
531 pub fn ex_dividend_date_lte(mut self, v: NaiveDate) -> Self {
532 self.ex_dividend_date_lte = Some(v);
533 self
534 }
535
536 #[must_use]
538 pub fn dividend_type(mut self, v: impl Into<String>) -> Self {
539 self.dividend_type = Some(v.into());
540 self
541 }
542
543 #[must_use]
545 pub fn limit(mut self, v: u16) -> Self {
546 self.limit = Some(v.min(1000));
547 self
548 }
549
550 #[must_use]
552 pub fn order(mut self, v: SortOrder) -> Self {
553 self.order = Some(v);
554 self
555 }
556
557 pub fn validate(&self) -> Result<(), MassiveError> {
563 let has_exact = self.ex_dividend_date.is_some();
564 let has_range = self.ex_dividend_date_gte.is_some() || self.ex_dividend_date_lte.is_some();
565
566 if has_exact && has_range {
567 return Err(MassiveError::InvalidInput {
568 message: "DividendQuery: cannot set both ex_dividend_date (exact) and \
569 ex_dividend_date_gte/lte (range) filters"
570 .into(),
571 });
572 }
573 Ok(())
574 }
575
576 fn to_query_string(&self) -> String {
578 let mut pairs: Vec<(&str, String)> = Vec::new();
579
580 if let Some(ref v) = self.ticker {
581 pairs.push(("ticker", v.clone()));
582 }
583 if let Some(v) = self.ex_dividend_date {
584 pairs.push(("ex_dividend_date", v.to_string()));
585 }
586 if let Some(v) = self.ex_dividend_date_gte {
587 pairs.push(("ex_dividend_date.gte", v.to_string()));
588 }
589 if let Some(v) = self.ex_dividend_date_lte {
590 pairs.push(("ex_dividend_date.lte", v.to_string()));
591 }
592 if let Some(ref v) = self.dividend_type {
593 pairs.push(("dividend_type", v.clone()));
594 }
595 if let Some(v) = self.limit {
596 pairs.push(("limit", v.to_string()));
597 }
598 if let Some(v) = self.order {
599 pairs.push(("order", v.as_str().to_string()));
600 }
601
602 if pairs.is_empty() {
603 String::new()
604 } else {
605 let encoded: Vec<String> = pairs
606 .into_iter()
607 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
608 .collect();
609 format!("?{}", encoded.join("&"))
610 }
611 }
612}
613
614#[derive(Debug, Default, Clone)]
616pub struct SplitQuery {
617 pub ticker: Option<String>,
619 pub execution_date: Option<NaiveDate>,
621 pub execution_date_gte: Option<NaiveDate>,
623 pub execution_date_lte: Option<NaiveDate>,
625 pub limit: Option<u16>,
627 pub order: Option<SortOrder>,
629}
630
631impl SplitQuery {
632 #[must_use]
634 pub fn new() -> Self {
635 Self::default()
636 }
637
638 #[must_use]
640 pub fn ticker(mut self, v: impl Into<String>) -> Self {
641 self.ticker = Some(v.into());
642 self
643 }
644
645 #[must_use]
647 pub fn execution_date(mut self, v: NaiveDate) -> Self {
648 self.execution_date = Some(v);
649 self
650 }
651
652 #[must_use]
654 pub fn execution_date_gte(mut self, v: NaiveDate) -> Self {
655 self.execution_date_gte = Some(v);
656 self
657 }
658
659 #[must_use]
661 pub fn execution_date_lte(mut self, v: NaiveDate) -> Self {
662 self.execution_date_lte = Some(v);
663 self
664 }
665
666 #[must_use]
668 pub fn limit(mut self, v: u16) -> Self {
669 self.limit = Some(v.min(1000));
670 self
671 }
672
673 #[must_use]
675 pub fn order(mut self, v: SortOrder) -> Self {
676 self.order = Some(v);
677 self
678 }
679
680 pub fn validate(&self) -> Result<(), MassiveError> {
686 let has_exact = self.execution_date.is_some();
687 let has_range = self.execution_date_gte.is_some() || self.execution_date_lte.is_some();
688
689 if has_exact && has_range {
690 return Err(MassiveError::InvalidInput {
691 message: "SplitQuery: cannot set both execution_date (exact) and \
692 execution_date_gte/lte (range) filters"
693 .into(),
694 });
695 }
696 Ok(())
697 }
698
699 fn to_query_string(&self) -> String {
701 let mut pairs: Vec<(&str, String)> = Vec::new();
702
703 if let Some(ref v) = self.ticker {
704 pairs.push(("ticker", v.clone()));
705 }
706 if let Some(v) = self.execution_date {
707 pairs.push(("execution_date", v.to_string()));
708 }
709 if let Some(v) = self.execution_date_gte {
710 pairs.push(("execution_date.gte", v.to_string()));
711 }
712 if let Some(v) = self.execution_date_lte {
713 pairs.push(("execution_date.lte", v.to_string()));
714 }
715 if let Some(v) = self.limit {
716 pairs.push(("limit", v.to_string()));
717 }
718 if let Some(v) = self.order {
719 pairs.push(("order", v.as_str().to_string()));
720 }
721
722 if pairs.is_empty() {
723 String::new()
724 } else {
725 let encoded: Vec<String> = pairs
726 .into_iter()
727 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
728 .collect();
729 format!("?{}", encoded.join("&"))
730 }
731 }
732}
733
734#[derive(Deserialize)]
739struct TickersResponse {
740 results: Option<Vec<RawTicker>>,
741 next_url: Option<String>,
742 #[allow(dead_code)]
744 status: Option<String>,
745 #[allow(dead_code)]
747 count: Option<u32>,
748}
749
750#[derive(Deserialize)]
751struct RawTicker {
752 ticker: String,
753 name: Option<String>,
754 market: Option<String>,
755 #[serde(rename = "type")]
756 asset_type: Option<String>,
757 active: Option<bool>,
758 primary_exchange: Option<String>,
759 currency_name: Option<String>,
760 composite_figi: Option<String>,
761 locale: Option<String>,
762 last_updated_utc: Option<String>,
763}
764
765impl RawTicker {
766 fn into_ticker(self) -> Ticker {
767 Ticker {
768 ticker: self.ticker,
769 name: self.name.unwrap_or_default(),
770 market: self.market.unwrap_or_default(),
771 asset_type: self.asset_type.unwrap_or_default(),
772 active: self.active.unwrap_or(true),
773 primary_exchange: self.primary_exchange,
774 currency_name: self.currency_name,
775 composite_figi: self.composite_figi,
776 locale: self.locale,
777 last_updated_utc: self.last_updated_utc.and_then(parse_rfc3339_or_warn),
778 }
779 }
780}
781
782fn parse_rfc3339_or_warn(s: String) -> Option<DateTime<Utc>> {
784 match DateTime::parse_from_rfc3339(&s) {
785 Ok(dt) => Some(dt.with_timezone(&Utc)),
786 Err(e) => {
787 warn!(value = %s, error = %e, "Failed to parse RFC3339 timestamp from Massive API");
788 None
789 }
790 }
791}
792
793#[derive(Deserialize)]
794struct TickerDetailsResponse {
795 results: Option<RawTickerDetails>,
796 #[allow(dead_code)]
798 status: Option<String>,
799}
800
801#[derive(Deserialize)]
802struct RawTickerDetails {
803 ticker: String,
804 name: Option<String>,
805 market: Option<String>,
806 #[serde(rename = "type")]
807 asset_type: Option<String>,
808 active: Option<bool>,
809 primary_exchange: Option<String>,
810 currency_name: Option<String>,
811 composite_figi: Option<String>,
812 locale: Option<String>,
813 last_updated_utc: Option<String>,
814 description: Option<String>,
815 homepage_url: Option<String>,
816 total_employees: Option<u64>,
817 market_cap: Option<f64>,
818 phone_number: Option<String>,
819 address: Option<RawAddress>,
820 sic_code: Option<String>,
821 sic_description: Option<String>,
822 ticker_root: Option<String>,
823 list_date: Option<String>,
824 delisted_utc: Option<String>,
825 share_class_shares_outstanding: Option<u64>,
826 weighted_shares_outstanding: Option<u64>,
827 round_lot: Option<u32>,
828}
829
830#[derive(Deserialize)]
831struct RawAddress {
832 address1: Option<String>,
833 city: Option<String>,
834 state: Option<String>,
835 postal_code: Option<String>,
836}
837
838impl RawTickerDetails {
839 fn into_ticker_details(self) -> TickerDetails {
840 let ticker = Ticker {
841 ticker: self.ticker,
842 name: self.name.unwrap_or_default(),
843 market: self.market.unwrap_or_default(),
844 asset_type: self.asset_type.unwrap_or_default(),
845 active: self.active.unwrap_or(true),
846 primary_exchange: self.primary_exchange,
847 currency_name: self.currency_name,
848 composite_figi: self.composite_figi,
849 locale: self.locale,
850 last_updated_utc: self.last_updated_utc.and_then(parse_rfc3339_or_warn),
851 };
852
853 TickerDetails {
854 ticker,
855 description: self.description,
856 homepage_url: self.homepage_url,
857 total_employees: self.total_employees,
858 market_cap: self.market_cap.and_then(|v| {
859 let d = Decimal::from_f64_retain(v);
860 if d.is_none() {
861 warn!(value = %v, "Non-finite market_cap from Massive API; dropping");
862 }
863 d
864 }),
865 phone_number: self.phone_number,
866 address: self.address.map(|a| Address {
867 address1: a.address1,
868 city: a.city,
869 state: a.state,
870 postal_code: a.postal_code,
871 }),
872 sic_code: self.sic_code,
873 sic_description: self.sic_description,
874 ticker_root: self.ticker_root,
875 list_date: self.list_date.and_then(|s| match s.parse() {
876 Ok(d) => Some(d),
877 Err(e) => {
878 warn!(value = %s, error = %e, "Failed to parse list_date from Massive API");
879 None
880 }
881 }),
882 delisted_utc: self.delisted_utc.and_then(parse_rfc3339_or_warn),
883 share_class_shares_outstanding: self.share_class_shares_outstanding,
884 weighted_shares_outstanding: self.weighted_shares_outstanding,
885 round_lot: self.round_lot,
886 }
887 }
888}
889
890#[derive(Deserialize)]
891struct ExchangesResponse {
892 results: Option<Vec<RawExchange>>,
893 #[allow(dead_code)]
895 status: Option<String>,
896}
897
898#[derive(Deserialize)]
899struct RawExchange {
900 id: i64,
901 name: String,
902 acronym: Option<String>,
903 mic: Option<String>,
904 operating_mic: Option<String>,
905 asset_class: Option<String>,
906 locale: Option<String>,
907 #[serde(rename = "type")]
908 exchange_type: Option<String>,
909 url: Option<String>,
910}
911
912impl RawExchange {
913 fn into_exchange(self) -> Exchange {
914 Exchange {
915 id: self.id,
916 name: self.name,
917 acronym: self.acronym,
918 mic: self.mic,
919 operating_mic: self.operating_mic,
920 asset_class: self.asset_class.unwrap_or_default(),
921 locale: self.locale.unwrap_or_default(),
922 exchange_type: self.exchange_type,
923 url: self.url,
924 }
925 }
926}
927
928#[derive(Deserialize)]
929struct RawMarketStatus {
930 market: Option<String>,
931 #[serde(rename = "serverTime")]
932 server_time: Option<String>,
933 #[serde(rename = "afterHours")]
934 after_hours: Option<bool>,
935 #[serde(rename = "earlyHours")]
936 early_hours: Option<bool>,
937 currencies: Option<RawCurrencyStatus>,
938 exchanges: Option<HashMap<String, String>>,
939 #[serde(rename = "indicesGroups")]
940 indices_groups: Option<HashMap<String, String>>,
941}
942
943#[derive(Deserialize)]
944struct RawCurrencyStatus {
945 crypto: Option<String>,
946 fx: Option<String>,
947}
948
949impl RawMarketStatus {
950 fn into_market_status(self) -> Result<MarketStatus, MassiveError> {
951 let server_time = match self.server_time {
952 Some(s) => DateTime::parse_from_rfc3339(&s)
953 .map(|dt| dt.with_timezone(&Utc))
954 .map_err(|e| MassiveError::Deserialize {
955 message: format!("invalid serverTime: {e}"),
956 payload: s,
957 })?,
958 None => {
959 return Err(MassiveError::Deserialize {
960 message: "missing serverTime".into(),
961 payload: String::new(),
962 });
963 }
964 };
965
966 Ok(MarketStatus {
967 market: self.market.unwrap_or_else(|| "unknown".into()),
968 server_time,
969 after_hours: self.after_hours.unwrap_or(false),
970 early_hours: self.early_hours.unwrap_or(false),
971 currencies: self
972 .currencies
973 .map(|c| CurrencyStatus {
974 crypto: c.crypto.unwrap_or_else(|| "unknown".into()),
975 fx: c.fx.unwrap_or_else(|| "unknown".into()),
976 })
977 .unwrap_or_default(),
978 exchanges: self.exchanges.unwrap_or_default(),
979 indices_groups: self.indices_groups.unwrap_or_default(),
980 })
981 }
982}
983
984#[derive(Deserialize)]
985struct RawMarketHoliday {
986 date: String,
987 exchange: Option<String>,
988 name: Option<String>,
989 status: Option<String>,
990 open: Option<String>,
991 close: Option<String>,
992}
993
994impl RawMarketHoliday {
995 fn into_market_holiday(self) -> Result<MarketHoliday, MassiveError> {
996 let date = self.date.parse().map_err(|e: chrono::format::ParseError| {
997 MassiveError::Deserialize {
998 message: format!("invalid date format: {e}"),
999 payload: self.date.clone(),
1000 }
1001 })?;
1002
1003 Ok(MarketHoliday {
1004 date,
1005 exchange: self.exchange.unwrap_or_default(),
1006 name: self.name.unwrap_or_default(),
1007 status: self.status.unwrap_or_else(|| "closed".into()),
1008 open: self.open.and_then(parse_rfc3339_or_warn),
1009 close: self.close.and_then(parse_rfc3339_or_warn),
1010 })
1011 }
1012}
1013
1014#[derive(Deserialize)]
1015struct DividendsResponse {
1016 results: Option<Vec<RawDividend>>,
1017 next_url: Option<String>,
1018 #[allow(dead_code)]
1020 status: Option<String>,
1021}
1022
1023#[derive(Deserialize)]
1024struct RawDividend {
1025 ticker: String,
1026 #[serde(with = "rust_decimal::serde::float")]
1027 cash_amount: Decimal,
1028 #[serde(default)]
1029 currency: Option<String>,
1030 declaration_date: Option<String>,
1031 ex_dividend_date: String,
1032 record_date: Option<String>,
1033 pay_date: Option<String>,
1034 frequency: Option<u8>,
1035 dividend_type: Option<String>,
1036}
1037
1038impl RawDividend {
1039 fn into_dividend(self) -> Result<Dividend, MassiveError> {
1040 let ex_dividend_date =
1041 self.ex_dividend_date
1042 .parse()
1043 .map_err(|e: chrono::format::ParseError| MassiveError::Deserialize {
1044 message: format!("invalid ex_dividend_date: {e}"),
1045 payload: self.ex_dividend_date.clone(),
1046 })?;
1047
1048 Ok(Dividend {
1049 ticker: self.ticker,
1050 cash_amount: self.cash_amount,
1051 currency: self.currency.unwrap_or_else(|| "USD".into()),
1052 declaration_date: self.declaration_date.and_then(|s| match s.parse() {
1053 Ok(d) => Some(d),
1054 Err(e) => {
1055 warn!(value = %s, error = %e, "Failed to parse declaration_date");
1056 None
1057 }
1058 }),
1059 ex_dividend_date,
1060 record_date: self.record_date.and_then(|s| match s.parse() {
1061 Ok(d) => Some(d),
1062 Err(e) => {
1063 warn!(value = %s, error = %e, "Failed to parse record_date");
1064 None
1065 }
1066 }),
1067 pay_date: self.pay_date.and_then(|s| match s.parse() {
1068 Ok(d) => Some(d),
1069 Err(e) => {
1070 warn!(value = %s, error = %e, "Failed to parse pay_date");
1071 None
1072 }
1073 }),
1074 frequency: self.frequency.map(DividendFrequency::from_raw),
1075 dividend_type: self.dividend_type,
1076 })
1077 }
1078}
1079
1080#[derive(Deserialize)]
1081struct SplitsResponse {
1082 results: Option<Vec<RawSplit>>,
1083 next_url: Option<String>,
1084 #[allow(dead_code)]
1086 status: Option<String>,
1087}
1088
1089#[derive(Deserialize)]
1090struct RawSplit {
1091 ticker: String,
1092 execution_date: String,
1093 #[serde(with = "rust_decimal::serde::float")]
1094 split_to: Decimal,
1095 #[serde(with = "rust_decimal::serde::float")]
1096 split_from: Decimal,
1097}
1098
1099impl RawSplit {
1100 fn into_stock_split(self) -> Result<StockSplit, MassiveError> {
1101 let execution_date =
1102 self.execution_date
1103 .parse()
1104 .map_err(|e: chrono::format::ParseError| MassiveError::Deserialize {
1105 message: format!("invalid execution_date: {e}"),
1106 payload: self.execution_date.clone(),
1107 })?;
1108
1109 Ok(StockSplit {
1110 ticker: self.ticker,
1111 execution_date,
1112 split_to: self.split_to,
1113 split_from: self.split_from,
1114 })
1115 }
1116}
1117
1118impl MassiveRestClient {
1123 pub fn fetch_tickers<'a>(
1137 &'a self,
1138 query: &'a TickerQuery,
1139 ) -> impl Stream<Item = Result<Ticker, MassiveError>> + 'a {
1140 let base_url = self.base_url();
1141 try_stream! {
1142 let initial_url = format!(
1143 "{}/v3/reference/tickers{}",
1144 base_url,
1145 query.to_query_string()
1146 );
1147
1148 let mut next_url: Option<String> = Some(initial_url);
1149 let mut guard = PaginationGuard::default();
1150
1151 while let Some(url) = next_url.take() {
1152 guard.observe(&url)?;
1153 debug!(url = %url, "Fetching tickers page");
1154
1155 let body = self.fetch_page_body(&url).await?;
1156 let parsed: TickersResponse = serde_json::from_str(&body).map_err(|e| {
1157 MassiveError::Deserialize {
1158 message: e.to_string(),
1159 payload: body,
1160 }
1161 })?;
1162
1163 if let Some(results) = parsed.results {
1164 for raw in results {
1165 yield raw.into_ticker();
1166 }
1167 }
1168
1169 next_url = parsed.next_url;
1170 }
1171 }
1172 }
1173
1174 pub async fn fetch_ticker_details(&self, ticker: &str) -> Result<TickerDetails, MassiveError> {
1187 Self::validate_ticker(ticker)?;
1188
1189 let url = format!("{}/v3/reference/tickers/{}", self.base_url(), ticker);
1190 debug!(url = %url, "Fetching ticker details");
1191
1192 let body = self.fetch_page_body(&url).await?;
1193 let parsed: TickerDetailsResponse =
1194 serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1195 message: e.to_string(),
1196 payload: body,
1197 })?;
1198
1199 parsed
1200 .results
1201 .map(|r| r.into_ticker_details())
1202 .ok_or_else(|| MassiveError::Api {
1203 status: 404,
1204 message: format!("Ticker not found: {}", ticker),
1205 })
1206 }
1207
1208 pub async fn fetch_exchanges(
1224 &self,
1225 asset_class: Option<&str>,
1226 locale: Option<&str>,
1227 ) -> Result<Vec<Exchange>, MassiveError> {
1228 let mut url = format!("{}/v3/reference/exchanges", self.base_url());
1229
1230 let mut params = Vec::new();
1231 if let Some(ac) = asset_class {
1232 params.push(format!("asset_class={}", urlencoding::encode(ac)));
1233 }
1234 if let Some(loc) = locale {
1235 params.push(format!("locale={}", urlencoding::encode(loc)));
1236 }
1237 if !params.is_empty() {
1238 url.push('?');
1239 url.push_str(¶ms.join("&"));
1240 }
1241
1242 debug!(url = %url, "Fetching exchanges");
1243
1244 let body = self.fetch_page_body(&url).await?;
1245 let parsed: ExchangesResponse =
1246 serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1247 message: e.to_string(),
1248 payload: body,
1249 })?;
1250
1251 Ok(parsed
1252 .results
1253 .unwrap_or_default()
1254 .into_iter()
1255 .map(|r| r.into_exchange())
1256 .collect())
1257 }
1258
1259 pub async fn fetch_market_status(&self) -> Result<MarketStatus, MassiveError> {
1270 let url = format!("{}/v1/marketstatus/now", self.base_url());
1271 debug!(url = %url, "Fetching market status");
1272
1273 let body = self.fetch_page_body(&url).await?;
1274 let parsed: RawMarketStatus =
1275 serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1276 message: e.to_string(),
1277 payload: body,
1278 })?;
1279
1280 parsed.into_market_status()
1281 }
1282
1283 pub async fn fetch_market_holidays(&self) -> Result<Vec<MarketHoliday>, MassiveError> {
1296 let url = format!("{}/v1/marketstatus/upcoming", self.base_url());
1297 debug!(url = %url, "Fetching market holidays");
1298
1299 let body = self.fetch_page_body(&url).await?;
1300 let parsed: Vec<RawMarketHoliday> =
1301 serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1302 message: e.to_string(),
1303 payload: body,
1304 })?;
1305
1306 parsed
1307 .into_iter()
1308 .map(|r| r.into_market_holiday())
1309 .collect()
1310 }
1311
1312 pub fn fetch_dividends<'a>(
1326 &'a self,
1327 query: &'a DividendQuery,
1328 ) -> impl Stream<Item = Result<Dividend, MassiveError>> + 'a {
1329 let base_url = self.base_url();
1330 try_stream! {
1331 query.validate()?;
1332
1333 let initial_url = format!(
1334 "{}/v3/reference/dividends{}",
1335 base_url,
1336 query.to_query_string()
1337 );
1338
1339 let mut next_url: Option<String> = Some(initial_url);
1340 let mut guard = PaginationGuard::default();
1341
1342 while let Some(url) = next_url.take() {
1343 guard.observe(&url)?;
1344 debug!(url = %url, "Fetching dividends page");
1345
1346 let body = self.fetch_page_body(&url).await?;
1347 let parsed: DividendsResponse = serde_json::from_str(&body).map_err(|e| {
1348 MassiveError::Deserialize {
1349 message: e.to_string(),
1350 payload: body,
1351 }
1352 })?;
1353
1354 if let Some(results) = parsed.results {
1355 for raw in results {
1356 yield raw.into_dividend()?;
1357 }
1358 }
1359
1360 next_url = parsed.next_url;
1361 }
1362 }
1363 }
1364
1365 pub fn fetch_splits_raw<'a>(
1379 &'a self,
1380 query: &'a SplitQuery,
1381 ) -> impl Stream<Item = Result<StockSplit, MassiveError>> + 'a {
1382 let base_url = self.base_url();
1383 try_stream! {
1384 query.validate()?;
1385
1386 let initial_url = format!(
1387 "{}/v3/reference/splits{}",
1388 base_url,
1389 query.to_query_string()
1390 );
1391
1392 let mut next_url: Option<String> = Some(initial_url);
1393 let mut guard = PaginationGuard::default();
1394
1395 while let Some(url) = next_url.take() {
1396 guard.observe(&url)?;
1397 debug!(url = %url, "Fetching splits page");
1398
1399 let body = self.fetch_page_body(&url).await?;
1400 let parsed: SplitsResponse = serde_json::from_str(&body).map_err(|e| {
1401 MassiveError::Deserialize {
1402 message: e.to_string(),
1403 payload: body,
1404 }
1405 })?;
1406
1407 if let Some(results) = parsed.results {
1408 for raw in results {
1409 yield raw.into_stock_split()?;
1410 }
1411 }
1412
1413 next_url = parsed.next_url;
1414 }
1415 }
1416 }
1417}
1418
1419#[cfg(test)]
1424#[allow(clippy::unwrap_used)] mod tests {
1426 use super::*;
1427
1428 #[test]
1429 fn test_ticker_query_empty() {
1430 let query = TickerQuery::new();
1431 assert_eq!(query.to_query_string(), "");
1432 }
1433
1434 #[test]
1435 fn test_ticker_query_single_param() {
1436 let query = TickerQuery::new().market("stocks");
1437 assert_eq!(query.to_query_string(), "?market=stocks");
1438 }
1439
1440 #[test]
1441 fn test_ticker_query_multiple_params() {
1442 let query = TickerQuery::new().market("stocks").active(true).limit(500);
1443 let qs = query.to_query_string();
1444 assert!(qs.contains("market=stocks"));
1445 assert!(qs.contains("active=true"));
1446 assert!(qs.contains("limit=500"));
1447 }
1448
1449 #[test]
1450 fn test_ticker_query_type_serialization() {
1451 let query = TickerQuery::new().asset_type("CS");
1452 assert_eq!(query.to_query_string(), "?type=CS");
1453 }
1454
1455 #[test]
1456 fn test_ticker_query_limit_clamping() {
1457 let query = TickerQuery::new().limit(5000);
1458 assert_eq!(query.limit, Some(1000));
1459 }
1460
1461 #[test]
1462 fn test_ticker_query_url_encoding() {
1463 let query = TickerQuery::new().search("apple inc");
1464 assert_eq!(query.to_query_string(), "?search=apple%20inc");
1465 }
1466
1467 #[test]
1468 fn test_ticker_query_range_operators() {
1469 let query = TickerQuery::new().ticker_gte("A").ticker_lt("B");
1470 let qs = query.to_query_string();
1471 assert!(qs.contains("ticker.gte=A"));
1472 assert!(qs.contains("ticker.lt=B"));
1473 }
1474
1475 #[test]
1476 fn test_parse_ticker_response() {
1477 let json = r#"{
1478 "results": [{
1479 "ticker": "AAPL",
1480 "name": "Apple Inc.",
1481 "market": "stocks",
1482 "type": "CS",
1483 "active": true,
1484 "primary_exchange": "XNAS",
1485 "currency_name": "usd"
1486 }],
1487 "status": "OK",
1488 "count": 1
1489 }"#;
1490
1491 let parsed: TickersResponse = serde_json::from_str(json).unwrap();
1492 let results = parsed.results.unwrap();
1493 assert_eq!(results.len(), 1);
1494
1495 let ticker = results.into_iter().next().unwrap().into_ticker();
1496 assert_eq!(ticker.ticker, "AAPL");
1497 assert_eq!(ticker.name, "Apple Inc.");
1498 assert_eq!(ticker.market, "stocks");
1499 assert_eq!(ticker.asset_type, "CS");
1500 assert!(ticker.active);
1501 assert_eq!(ticker.primary_exchange, Some("XNAS".into()));
1502 }
1503
1504 #[test]
1505 fn test_parse_ticker_details_response() {
1506 let json = r#"{
1507 "results": {
1508 "ticker": "AAPL",
1509 "name": "Apple Inc.",
1510 "market": "stocks",
1511 "type": "CS",
1512 "active": true,
1513 "description": "Apple Inc. designs, manufactures, and markets smartphones.",
1514 "market_cap": 2500000000000.0,
1515 "total_employees": 164000,
1516 "homepage_url": "https://www.apple.com",
1517 "address": {
1518 "address1": "One Apple Park Way",
1519 "city": "Cupertino",
1520 "state": "CA",
1521 "postal_code": "95014"
1522 },
1523 "list_date": "1980-12-12"
1524 },
1525 "status": "OK"
1526 }"#;
1527
1528 let parsed: TickerDetailsResponse = serde_json::from_str(json).unwrap();
1529 let details = parsed.results.unwrap().into_ticker_details();
1530
1531 assert_eq!(details.ticker.ticker, "AAPL");
1532 assert!(details.description.unwrap().contains("Apple"));
1533 assert_eq!(
1534 details.market_cap,
1535 Decimal::from_f64_retain(2_500_000_000_000.0)
1536 );
1537 assert_eq!(details.total_employees, Some(164_000));
1538 assert!(details.address.is_some());
1539 let addr = details.address.unwrap();
1540 assert_eq!(addr.city, Some("Cupertino".into()));
1541 assert_eq!(
1542 details.list_date,
1543 Some(NaiveDate::from_ymd_opt(1980, 12, 12).unwrap())
1544 );
1545 }
1546
1547 #[test]
1548 fn test_parse_exchanges_response() {
1549 let json = r#"{
1550 "results": [{
1551 "id": 1,
1552 "name": "NYSE American, LLC",
1553 "acronym": "AMEX",
1554 "mic": "XASE",
1555 "operating_mic": "XNYS",
1556 "asset_class": "stocks",
1557 "locale": "us",
1558 "type": "exchange"
1559 }],
1560 "status": "OK"
1561 }"#;
1562
1563 let parsed: ExchangesResponse = serde_json::from_str(json).unwrap();
1564 let results = parsed.results.unwrap();
1565 assert_eq!(results.len(), 1);
1566
1567 let exchange = results.into_iter().next().unwrap().into_exchange();
1568 assert_eq!(exchange.id, 1);
1569 assert_eq!(exchange.name, "NYSE American, LLC");
1570 assert_eq!(exchange.mic, Some("XASE".into()));
1571 assert_eq!(exchange.asset_class, "stocks");
1572 }
1573
1574 #[test]
1575 fn test_parse_market_status_response() {
1576 let json = r#"{
1577 "market": "open",
1578 "serverTime": "2026-05-06T14:30:00-04:00",
1579 "afterHours": false,
1580 "earlyHours": false,
1581 "currencies": {
1582 "crypto": "open",
1583 "fx": "open"
1584 },
1585 "exchanges": {
1586 "nasdaq": "open",
1587 "nyse": "open"
1588 },
1589 "indicesGroups": {
1590 "s_and_p": "open"
1591 }
1592 }"#;
1593
1594 let parsed: RawMarketStatus = serde_json::from_str(json).unwrap();
1595 let status = parsed.into_market_status().unwrap();
1596
1597 assert_eq!(status.market, "open");
1598 assert!(!status.after_hours);
1599 assert_eq!(status.currencies.crypto, "open");
1600 assert_eq!(status.exchanges.get("nasdaq"), Some(&"open".into()));
1601 }
1602
1603 #[test]
1604 fn test_parse_market_holidays_response() {
1605 let json = r#"[
1606 {
1607 "date": "2026-12-25",
1608 "exchange": "NYSE",
1609 "name": "Christmas",
1610 "status": "closed"
1611 },
1612 {
1613 "date": "2026-11-27",
1614 "exchange": "NYSE",
1615 "name": "Thanksgiving",
1616 "status": "early-close",
1617 "open": "2026-11-27T13:30:00Z",
1618 "close": "2026-11-27T18:00:00Z"
1619 }
1620 ]"#;
1621
1622 let parsed: Vec<RawMarketHoliday> = serde_json::from_str(json).unwrap();
1623 let holidays: Vec<MarketHoliday> = parsed
1624 .into_iter()
1625 .map(|r| r.into_market_holiday().unwrap())
1626 .collect();
1627
1628 assert_eq!(holidays.len(), 2);
1629 assert_eq!(holidays[0].name, "Christmas");
1630 assert_eq!(holidays[0].status, "closed");
1631 assert!(holidays[0].open.is_none());
1632
1633 assert_eq!(holidays[1].name, "Thanksgiving");
1634 assert_eq!(holidays[1].status, "early-close");
1635 assert!(holidays[1].open.is_some());
1636 assert!(holidays[1].close.is_some());
1637 }
1638
1639 #[test]
1640 fn test_sort_order() {
1641 assert_eq!(SortOrder::Asc.as_str(), "asc");
1642 assert_eq!(SortOrder::Desc.as_str(), "desc");
1643 assert_eq!(SortOrder::default(), SortOrder::Asc);
1644 }
1645
1646 #[test]
1651 fn test_dividend_query_empty() {
1652 let query = DividendQuery::new();
1653 assert_eq!(query.to_query_string(), "");
1654 }
1655
1656 #[test]
1657 fn test_dividend_query_with_ticker() {
1658 let query = DividendQuery::new().ticker("AAPL");
1659 assert_eq!(query.to_query_string(), "?ticker=AAPL");
1660 }
1661
1662 #[test]
1663 fn test_dividend_query_with_date_range() {
1664 let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1665 let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1666 let query = DividendQuery::new()
1667 .ticker("AAPL")
1668 .ex_dividend_date_gte(from)
1669 .ex_dividend_date_lte(to);
1670 let qs = query.to_query_string();
1671 assert!(qs.contains("ticker=AAPL"));
1672 assert!(qs.contains("ex_dividend_date.gte=2024-01-01"));
1673 assert!(qs.contains("ex_dividend_date.lte=2024-12-31"));
1674 }
1675
1676 #[test]
1677 fn test_dividend_query_limit_clamping() {
1678 let query = DividendQuery::new().limit(5000);
1679 assert_eq!(query.limit, Some(1000));
1680 }
1681
1682 #[test]
1683 fn test_parse_dividends_response() {
1684 let json = r#"{
1685 "results": [{
1686 "ticker": "AAPL",
1687 "cash_amount": 0.25,
1688 "currency": "USD",
1689 "declaration_date": "2024-02-01",
1690 "ex_dividend_date": "2024-02-09",
1691 "record_date": "2024-02-12",
1692 "pay_date": "2024-02-15",
1693 "frequency": 4,
1694 "dividend_type": "CD"
1695 }],
1696 "status": "OK"
1697 }"#;
1698
1699 let parsed: DividendsResponse = serde_json::from_str(json).unwrap();
1700 let results = parsed.results.unwrap();
1701 assert_eq!(results.len(), 1);
1702
1703 let dividend = results.into_iter().next().unwrap().into_dividend().unwrap();
1704 assert_eq!(dividend.ticker, "AAPL");
1705 assert_eq!(
1706 dividend.cash_amount,
1707 Decimal::from_f64_retain(0.25).unwrap()
1708 );
1709 assert_eq!(dividend.currency, "USD");
1710 assert_eq!(
1711 dividend.ex_dividend_date,
1712 NaiveDate::from_ymd_opt(2024, 2, 9).unwrap()
1713 );
1714 assert_eq!(dividend.frequency, Some(DividendFrequency::Quarterly));
1715 assert_eq!(dividend.dividend_type, Some("CD".into()));
1716 }
1717
1718 #[test]
1719 fn test_parse_dividends_minimal() {
1720 let json = r#"{
1721 "results": [{
1722 "ticker": "MSFT",
1723 "cash_amount": 0.75,
1724 "ex_dividend_date": "2024-05-15"
1725 }],
1726 "status": "OK"
1727 }"#;
1728
1729 let parsed: DividendsResponse = serde_json::from_str(json).unwrap();
1730 let results = parsed.results.unwrap();
1731 let dividend = results.into_iter().next().unwrap().into_dividend().unwrap();
1732
1733 assert_eq!(dividend.ticker, "MSFT");
1734 assert_eq!(dividend.currency, "USD"); assert!(dividend.declaration_date.is_none());
1736 assert!(dividend.frequency.is_none());
1737 }
1738
1739 #[test]
1740 fn test_split_query_empty() {
1741 let query = SplitQuery::new();
1742 assert_eq!(query.to_query_string(), "");
1743 }
1744
1745 #[test]
1746 fn test_split_query_with_ticker() {
1747 let query = SplitQuery::new().ticker("TSLA");
1748 assert_eq!(query.to_query_string(), "?ticker=TSLA");
1749 }
1750
1751 #[test]
1752 fn test_split_query_with_date_range() {
1753 let from = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
1754 let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1755 let query = SplitQuery::new()
1756 .ticker("NVDA")
1757 .execution_date_gte(from)
1758 .execution_date_lte(to);
1759 let qs = query.to_query_string();
1760 assert!(qs.contains("ticker=NVDA"));
1761 assert!(qs.contains("execution_date.gte=2020-01-01"));
1762 assert!(qs.contains("execution_date.lte=2024-12-31"));
1763 }
1764
1765 #[test]
1766 fn test_split_query_limit_clamping() {
1767 let query = SplitQuery::new().limit(2000);
1768 assert_eq!(query.limit, Some(1000));
1769 }
1770
1771 #[test]
1772 fn test_parse_splits_response() {
1773 let json = r#"{
1774 "results": [{
1775 "ticker": "TSLA",
1776 "execution_date": "2022-08-25",
1777 "split_to": 3.0,
1778 "split_from": 1.0
1779 }],
1780 "status": "OK"
1781 }"#;
1782
1783 let parsed: SplitsResponse = serde_json::from_str(json).unwrap();
1784 let results = parsed.results.unwrap();
1785 assert_eq!(results.len(), 1);
1786
1787 let split = results
1788 .into_iter()
1789 .next()
1790 .unwrap()
1791 .into_stock_split()
1792 .unwrap();
1793 assert_eq!(split.ticker, "TSLA");
1794 assert_eq!(
1795 split.execution_date,
1796 NaiveDate::from_ymd_opt(2022, 8, 25).unwrap()
1797 );
1798 assert_eq!(split.split_to, Decimal::from(3));
1799 assert_eq!(split.split_from, Decimal::from(1));
1800 }
1801
1802 #[test]
1803 fn test_parse_splits_reverse_split() {
1804 let json = r#"{
1805 "results": [{
1806 "ticker": "GE",
1807 "execution_date": "2021-08-02",
1808 "split_to": 1.0,
1809 "split_from": 8.0
1810 }],
1811 "status": "OK"
1812 }"#;
1813
1814 let parsed: SplitsResponse = serde_json::from_str(json).unwrap();
1815 let results = parsed.results.unwrap();
1816 let split = results
1817 .into_iter()
1818 .next()
1819 .unwrap()
1820 .into_stock_split()
1821 .unwrap();
1822
1823 assert_eq!(split.ticker, "GE");
1824 assert_eq!(split.split_to, Decimal::from(1));
1825 assert_eq!(split.split_from, Decimal::from(8)); }
1827}