1use std::{collections::BTreeSet, fmt, str::FromStr, time::Duration};
2
3use serde::Deserialize;
4
5use super::{WebSocketConfig, WebSocketError};
6
7const STREAM_NAMES: [&str; 14] = [
8 "nasdaq-basic-w-nls-plus",
9 "iex-tops",
10 "tsx-level-1",
11 "tsxv-level-1",
12 "lse-level-1",
13 "cboe-index-main",
14 "fmp-crypto-stream",
15 "fmp-index-stream",
16 "fmp-commodity-stream",
17 "fmp-currency-stream",
18 "fmp-us-equities-stream",
19 "fmp-us-otc-stream",
20 "fmp-uk-equities-stream",
21 "fmp-ca-equities-stream",
22];
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
26pub struct MarketStream(String);
27
28impl MarketStream {
29 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33}
34
35impl FromStr for MarketStream {
36 type Err = WebSocketError;
37
38 fn from_str(input: &str) -> Result<Self, Self::Err> {
39 let name = input.strip_suffix("-delayed").unwrap_or(input);
40 if !STREAM_NAMES.contains(&name) {
41 return Err(WebSocketError::Configuration {
42 reason: format!(
43 "unknown FMP WebSocket stream {input:?}; use a stream listed in FMP's standard WebSocket documentation, optionally ending in -delayed"
44 ),
45 });
46 }
47 Ok(Self(input.to_owned()))
48 }
49}
50
51impl fmt::Display for MarketStream {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 formatter.write_str(self.as_str())
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct StreamEntitlements {
63 streams: BTreeSet<MarketStream>,
64}
65
66impl StreamEntitlements {
67 pub fn new(streams: Vec<MarketStream>) -> Result<Self, WebSocketError> {
69 if streams.is_empty() {
70 return Err(WebSocketError::Configuration {
71 reason: "configure at least one explicitly granted FMP WebSocket stream".to_owned(),
72 });
73 }
74 Ok(Self {
75 streams: streams.into_iter().collect(),
76 })
77 }
78
79 pub fn allows(&self, stream: &MarketStream) -> bool {
81 self.streams.contains(stream)
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct StreamConfig {
88 pub(super) config: WebSocketConfig,
89}
90
91impl StreamConfig {
92 pub fn new(
98 handshake_timeout: Duration,
99 receive_timeout: Duration,
100 connect_attempts: u32,
101 retry_delay: Duration,
102 ) -> Result<Self, WebSocketError> {
103 Ok(Self {
104 config: WebSocketConfig::new(
105 handshake_timeout,
106 receive_timeout,
107 connect_attempts,
108 retry_delay,
109 super::protocol::confirm_standard_login,
110 )?,
111 })
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
117pub struct AuthenticationStatus {
118 pub status: u16,
120 pub message: String,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
126pub struct Heartbeat {
127 pub timestamp: u64,
129}
130
131#[derive(Debug, Clone, PartialEq, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct StreamQuote {
139 pub symbol: String,
141 pub name: Option<String>,
143 pub price: f64,
145 pub changes_percentage: Option<f64>,
147 pub change: Option<f64>,
149 pub day_low: Option<f64>,
151 pub day_high: Option<f64>,
153 pub year_low: Option<f64>,
155 pub year_high: Option<f64>,
157 pub market_cap: Option<f64>,
159 pub price_avg50: Option<f64>,
161 pub price_avg200: Option<f64>,
163 pub exchange: Option<String>,
165 pub volume: Option<f64>,
167 pub avg_volume: Option<f64>,
169 pub open: Option<f64>,
171 pub previous_close: Option<f64>,
173 pub eps: Option<f64>,
175 pub pe: Option<f64>,
177 pub earnings_announcement: Option<String>,
179 pub shares_outstanding: Option<f64>,
181 pub timestamp: u64,
183}
184
185pub(super) fn validate_stream_quote(quote: &StreamQuote) -> Result<(), &'static str> {
186 if quote.symbol.is_empty()
187 || quote
188 .symbol
189 .chars()
190 .any(|character| character.is_whitespace() || character.is_control())
191 {
192 return Err(
193 "quote symbol must be nonempty and contain no whitespace or control characters",
194 );
195 }
196 for value in [quote.name.as_ref(), quote.exchange.as_ref()]
197 .into_iter()
198 .flatten()
199 {
200 if value.trim().is_empty() || value.chars().any(char::is_control) {
201 return Err(
202 "quote name and exchange must be nonempty and contain no control characters when supplied",
203 );
204 }
205 }
206 for value in [
207 Some(quote.price),
208 quote.day_low,
209 quote.day_high,
210 quote.year_low,
211 quote.year_high,
212 quote.price_avg50,
213 quote.price_avg200,
214 quote.open,
215 quote.previous_close,
216 ]
217 .into_iter()
218 .flatten()
219 {
220 if !value.is_finite() {
221 return Err("quote prices must be finite; negative commodity prices are permitted");
222 }
223 }
224 for value in [
225 quote.market_cap,
226 quote.volume,
227 quote.avg_volume,
228 quote.shares_outstanding,
229 ]
230 .into_iter()
231 .flatten()
232 {
233 if !value.is_finite() || value < 0.0 {
234 return Err(
235 "quote market capitalization, volumes, and outstanding shares must be finite and nonnegative",
236 );
237 }
238 }
239 for value in [quote.change, quote.changes_percentage, quote.eps, quote.pe]
240 .into_iter()
241 .flatten()
242 {
243 if !value.is_finite() {
244 return Err(
245 "quote changes, earnings per share, and price-to-earnings ratio must be finite",
246 );
247 }
248 }
249 if quote
250 .earnings_announcement
251 .as_ref()
252 .is_some_and(|value| value.trim().is_empty() || value.chars().any(char::is_control))
253 {
254 return Err(
255 "quote earnings announcement must be nonempty and contain no control characters when supplied",
256 );
257 }
258 Ok(())
259}