alpaca-data 0.25.1

Rust client for the Alpaca Market Data HTTP API
Documentation
use std::collections::HashMap;

use rust_decimal::Decimal;

use super::{Bar, DataFeed, Snapshot};

#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct BarPoint {
    pub timestamp: String,
    pub open: Decimal,
    pub high: Decimal,
    pub low: Decimal,
    pub close: Decimal,
    pub volume: i64,
}

fn timestamp_parts<'a>(
    latest_trade: Option<&'a str>,
    latest_quote: Option<&'a str>,
    minute_bar: Option<&'a str>,
    daily_bar: Option<&'a str>,
    prev_daily_bar: Option<&'a str>,
) -> Option<&'a str> {
    [
        latest_trade,
        latest_quote,
        minute_bar,
        daily_bar,
        prev_daily_bar,
    ]
    .into_iter()
    .flatten()
    .filter(|value| !value.trim().is_empty())
    .max()
}

fn price_parts(
    latest_trade: Option<Decimal>,
    bid: Option<Decimal>,
    ask: Option<Decimal>,
    minute_close: Option<Decimal>,
    daily_close: Option<Decimal>,
    prev_daily_close: Option<Decimal>,
) -> Option<Decimal> {
    latest_trade
        .or_else(|| match (bid, ask) {
            (Some(bid), Some(ask)) => Some((bid + ask) / Decimal::from(2u8)),
            (Some(bid), None) => Some(bid),
            (None, Some(ask)) => Some(ask),
            (None, None) => None,
        })
        .or(minute_close)
        .or(daily_close)
        .or(prev_daily_close)
}

fn quote_bid(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.latest_quote.as_ref().and_then(|quote| quote.bp)
}

fn quote_ask(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.latest_quote.as_ref().and_then(|quote| quote.ap)
}

fn session_open(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.daily_bar.as_ref().and_then(|bar| bar.o)
}

fn session_high(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.daily_bar.as_ref().and_then(|bar| bar.h)
}

fn session_low(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.daily_bar.as_ref().and_then(|bar| bar.l)
}

fn session_close(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.daily_bar.as_ref().and_then(|bar| bar.c)
}

fn previous_close(snapshot: &Snapshot) -> Option<Decimal> {
    snapshot.prev_daily_bar.as_ref().and_then(|bar| bar.c)
}

fn session_volume(snapshot: &Snapshot) -> Option<u64> {
    snapshot.daily_bar.as_ref().and_then(|bar| bar.v)
}

impl Snapshot {
    #[must_use]
    pub fn timestamp(&self) -> Option<&str> {
        timestamp_parts(
            self.latest_trade
                .as_ref()
                .and_then(|trade| trade.t.as_deref()),
            self.latest_quote
                .as_ref()
                .and_then(|quote| quote.t.as_deref()),
            self.minute_bar.as_ref().and_then(|bar| bar.t.as_deref()),
            self.daily_bar.as_ref().and_then(|bar| bar.t.as_deref()),
            self.prev_daily_bar
                .as_ref()
                .and_then(|bar| bar.t.as_deref()),
        )
    }

    #[must_use]
    pub fn price(&self) -> Option<Decimal> {
        price_parts(
            self.latest_trade.as_ref().and_then(|trade| trade.p),
            self.bid_price(),
            self.ask_price(),
            self.minute_bar.as_ref().and_then(|bar| bar.c),
            self.session_close(),
            self.previous_close(),
        )
    }

    #[must_use]
    pub fn bid_price(&self) -> Option<Decimal> {
        quote_bid(self)
    }

    #[must_use]
    pub fn ask_price(&self) -> Option<Decimal> {
        quote_ask(self)
    }

    #[must_use]
    pub fn session_open(&self) -> Option<Decimal> {
        session_open(self)
    }

    #[must_use]
    pub fn session_high(&self) -> Option<Decimal> {
        session_high(self)
    }

    #[must_use]
    pub fn session_low(&self) -> Option<Decimal> {
        session_low(self)
    }

    #[must_use]
    pub fn session_close(&self) -> Option<Decimal> {
        session_close(self)
    }

    #[must_use]
    pub fn previous_close(&self) -> Option<Decimal> {
        previous_close(self)
    }

    #[must_use]
    pub fn session_volume(&self) -> Option<u64> {
        session_volume(self)
    }
}

impl Bar {
    #[must_use]
    pub fn point(&self, daily: bool) -> BarPoint {
        let raw_timestamp = self.t.clone().unwrap_or_default();
        let timestamp = if daily {
            raw_timestamp
                .get(..10)
                .unwrap_or(raw_timestamp.as_str())
                .to_owned()
        } else {
            raw_timestamp
        };

        BarPoint {
            timestamp,
            open: self.o.unwrap_or_default(),
            high: self.h.unwrap_or_default(),
            low: self.l.unwrap_or_default(),
            close: self.c.unwrap_or_default(),
            volume: match self.v {
                Some(value) => i64::try_from(value).unwrap_or(i64::MAX),
                None => 0,
            },
        }
    }
}

#[must_use]
pub fn ordered_snapshots(snapshots: &HashMap<String, Snapshot>) -> Vec<(&str, &Snapshot)> {
    let mut symbols = snapshots.keys().map(String::as_str).collect::<Vec<_>>();
    symbols.sort_unstable();
    symbols
        .into_iter()
        .filter_map(|symbol| {
            snapshots
                .get_key_value(symbol)
                .map(|(symbol, snapshot)| (symbol.as_str(), snapshot))
        })
        .collect()
}

#[must_use]
pub fn preferred_feed(extended_hours: bool) -> DataFeed {
    if extended_hours {
        DataFeed::Boats
    } else {
        DataFeed::Sip
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use rust_decimal::Decimal;

    use super::{BarPoint, Snapshot, ordered_snapshots, preferred_feed};
    use crate::stocks::{Bar, DataFeed, Quote, Trade};

    #[test]
    fn snapshot_timestamp_prefers_the_freshest_available_value() {
        let snapshot = Snapshot {
            latest_trade: Some(Trade {
                t: Some("2026-04-13T13:30:01Z".to_owned()),
                ..Trade::default()
            }),
            latest_quote: Some(Quote {
                t: Some("2026-04-13T13:30:05Z".to_owned()),
                ..Quote::default()
            }),
            minute_bar: Some(Bar {
                t: Some("2026-04-13T13:30:00Z".to_owned()),
                ..Bar::default()
            }),
            ..Snapshot::default()
        };

        assert_eq!(snapshot.timestamp(), Some("2026-04-13T13:30:05Z"));
    }

    #[test]
    fn snapshot_price_absorbs_single_sided_quotes_and_trade_fallbacks() {
        let with_both_sides = Snapshot {
            latest_quote: Some(Quote {
                bp: Some(Decimal::new(125, 2)),
                ap: Some(Decimal::new(135, 2)),
                ..Quote::default()
            }),
            ..Snapshot::default()
        };
        let with_bid_only = Snapshot {
            latest_quote: Some(Quote {
                bp: Some(Decimal::new(125, 2)),
                ..Quote::default()
            }),
            ..Snapshot::default()
        };
        let with_trade = Snapshot {
            latest_trade: Some(Trade {
                p: Some(Decimal::new(141, 2)),
                ..Trade::default()
            }),
            latest_quote: Some(Quote {
                bp: Some(Decimal::new(125, 2)),
                ap: Some(Decimal::new(135, 2)),
                ..Quote::default()
            }),
            ..Snapshot::default()
        };

        assert_eq!(with_both_sides.price(), Some(Decimal::new(130, 2)));
        assert_eq!(with_bid_only.price(), Some(Decimal::new(125, 2)));
        assert_eq!(with_trade.price(), Some(Decimal::new(141, 2)));
    }

    #[test]
    fn snapshot_canonical_session_readers_hide_provider_nesting() {
        let snapshot = Snapshot {
            latest_quote: Some(Quote {
                bp: Some(Decimal::new(50000, 2)),
                ap: Some(Decimal::new(50030, 2)),
                ..Quote::default()
            }),
            daily_bar: Some(Bar {
                o: Some(Decimal::new(49810, 2)),
                h: Some(Decimal::new(50320, 2)),
                l: Some(Decimal::new(49750, 2)),
                c: Some(Decimal::new(50140, 2)),
                v: Some(1_234_567),
                ..Bar::default()
            }),
            prev_daily_bar: Some(Bar {
                c: Some(Decimal::new(49680, 2)),
                ..Bar::default()
            }),
            ..Snapshot::default()
        };

        assert_eq!(snapshot.bid_price(), Some(Decimal::new(50000, 2)));
        assert_eq!(snapshot.ask_price(), Some(Decimal::new(50030, 2)));
        assert_eq!(snapshot.session_open(), Some(Decimal::new(49810, 2)));
        assert_eq!(snapshot.session_high(), Some(Decimal::new(50320, 2)));
        assert_eq!(snapshot.session_low(), Some(Decimal::new(49750, 2)));
        assert_eq!(snapshot.session_close(), Some(Decimal::new(50140, 2)));
        assert_eq!(snapshot.previous_close(), Some(Decimal::new(49680, 2)));
        assert_eq!(snapshot.session_volume(), Some(1_234_567));
    }

    #[test]
    fn ordered_snapshots_returns_stable_symbol_order() {
        let mut snapshots = HashMap::new();
        snapshots.insert("QQQ".to_owned(), Snapshot::default());
        snapshots.insert("AAPL".to_owned(), Snapshot::default());

        let ordered = ordered_snapshots(&snapshots);
        assert_eq!(ordered[0].0, "AAPL");
        assert_eq!(ordered[1].0, "QQQ");
    }

    #[test]
    fn preferred_feed_uses_premium_stock_feeds() {
        assert_eq!(preferred_feed(false), DataFeed::Sip);
        assert_eq!(preferred_feed(true), DataFeed::Boats);
    }

    #[test]
    fn bar_point_normalizes_daily_timestamp_and_missing_fields() {
        let bar = Bar {
            t: Some("2026-04-17T20:00:00Z".to_owned()),
            c: Some(Decimal::new(51234, 2)),
            ..Bar::default()
        };

        assert_eq!(
            bar.point(true),
            BarPoint {
                timestamp: "2026-04-17".to_owned(),
                open: Decimal::ZERO,
                high: Decimal::ZERO,
                low: Decimal::ZERO,
                close: Decimal::new(51234, 2),
                volume: 0,
            }
        );
    }
}