use std::{collections::BTreeSet, fmt, str::FromStr, time::Duration};
use serde::Deserialize;
use super::{WebSocketConfig, WebSocketError};
const STREAM_NAMES: [&str; 14] = [
"nasdaq-basic-w-nls-plus",
"iex-tops",
"tsx-level-1",
"tsxv-level-1",
"lse-level-1",
"cboe-index-main",
"fmp-crypto-stream",
"fmp-index-stream",
"fmp-commodity-stream",
"fmp-currency-stream",
"fmp-us-equities-stream",
"fmp-us-otc-stream",
"fmp-uk-equities-stream",
"fmp-ca-equities-stream",
];
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct MarketStream(String);
impl MarketStream {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for MarketStream {
type Err = WebSocketError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let name = input.strip_suffix("-delayed").unwrap_or(input);
if !STREAM_NAMES.contains(&name) {
return Err(WebSocketError::Configuration {
reason: format!(
"unknown FMP WebSocket stream {input:?}; use a stream listed in FMP's standard WebSocket documentation, optionally ending in -delayed"
),
});
}
Ok(Self(input.to_owned()))
}
}
impl fmt::Display for MarketStream {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamEntitlements {
streams: BTreeSet<MarketStream>,
}
impl StreamEntitlements {
pub fn new(streams: Vec<MarketStream>) -> Result<Self, WebSocketError> {
if streams.is_empty() {
return Err(WebSocketError::Configuration {
reason: "configure at least one explicitly granted FMP WebSocket stream".to_owned(),
});
}
Ok(Self {
streams: streams.into_iter().collect(),
})
}
pub fn allows(&self, stream: &MarketStream) -> bool {
self.streams.contains(stream)
}
}
#[derive(Debug, Clone)]
pub struct StreamConfig {
pub(super) config: WebSocketConfig,
}
impl StreamConfig {
pub fn new(
handshake_timeout: Duration,
receive_timeout: Duration,
connect_attempts: u32,
retry_delay: Duration,
) -> Result<Self, WebSocketError> {
Ok(Self {
config: WebSocketConfig::new(
handshake_timeout,
receive_timeout,
connect_attempts,
retry_delay,
super::protocol::confirm_standard_login,
)?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct AuthenticationStatus {
pub status: u16,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Heartbeat {
pub timestamp: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamQuote {
pub symbol: String,
pub name: Option<String>,
pub price: f64,
pub changes_percentage: Option<f64>,
pub change: Option<f64>,
pub day_low: Option<f64>,
pub day_high: Option<f64>,
pub year_low: Option<f64>,
pub year_high: Option<f64>,
pub market_cap: Option<f64>,
pub price_avg50: Option<f64>,
pub price_avg200: Option<f64>,
pub exchange: Option<String>,
pub volume: Option<f64>,
pub avg_volume: Option<f64>,
pub open: Option<f64>,
pub previous_close: Option<f64>,
pub eps: Option<f64>,
pub pe: Option<f64>,
pub earnings_announcement: Option<String>,
pub shares_outstanding: Option<f64>,
pub timestamp: u64,
}
pub(super) fn validate_stream_quote(quote: &StreamQuote) -> Result<(), &'static str> {
if quote.symbol.is_empty()
|| quote
.symbol
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return Err(
"quote symbol must be nonempty and contain no whitespace or control characters",
);
}
for value in [quote.name.as_ref(), quote.exchange.as_ref()]
.into_iter()
.flatten()
{
if value.trim().is_empty() || value.chars().any(char::is_control) {
return Err(
"quote name and exchange must be nonempty and contain no control characters when supplied",
);
}
}
for value in [
Some(quote.price),
quote.day_low,
quote.day_high,
quote.year_low,
quote.year_high,
quote.price_avg50,
quote.price_avg200,
quote.open,
quote.previous_close,
]
.into_iter()
.flatten()
{
if !value.is_finite() {
return Err("quote prices must be finite; negative commodity prices are permitted");
}
}
for value in [
quote.market_cap,
quote.volume,
quote.avg_volume,
quote.shares_outstanding,
]
.into_iter()
.flatten()
{
if !value.is_finite() || value < 0.0 {
return Err(
"quote market capitalization, volumes, and outstanding shares must be finite and nonnegative",
);
}
}
for value in [quote.change, quote.changes_percentage, quote.eps, quote.pe]
.into_iter()
.flatten()
{
if !value.is_finite() {
return Err(
"quote changes, earnings per share, and price-to-earnings ratio must be finite",
);
}
}
if quote
.earnings_announcement
.as_ref()
.is_some_and(|value| value.trim().is_empty() || value.chars().any(char::is_control))
{
return Err(
"quote earnings announcement must be nonempty and contain no control characters when supplied",
);
}
Ok(())
}