1use super::channel::AlpacaChannel;
2use crate::{
3 Identifier,
4 books::Level,
5 error::DataError,
6 event::MarketEvent,
7 exchange::ExchangeSub,
8 subscription::{
9 Map, SubscriptionKind,
10 book::OrderBookL1,
11 quote::{Quote, Quotes},
12 },
13 transformer::ExchangeTransformer,
14};
15use chrono::{DateTime, Utc};
16use rust_decimal::Decimal;
17use rustrade_instrument::exchange::ExchangeId;
18use rustrade_integration::{
19 Transformer, protocol::websocket::WsMessage, subscription::SubscriptionId,
20};
21use serde::{Deserialize, Deserializer};
22use smol_str::SmolStr;
23use std::marker::PhantomData;
24use tokio::sync::mpsc;
25
26fn de_quote_subscription_id<'de, D>(deserializer: D) -> Result<SubscriptionId, D::Error>
28where
29 D: serde::de::Deserializer<'de>,
30{
31 <&str as Deserialize>::deserialize(deserializer)
32 .map(|symbol| ExchangeSub::from((AlpacaChannel::Quotes, symbol)).id())
33}
34
35#[derive(Debug, Deserialize)]
49pub struct AlpacaQuote {
50 #[serde(rename = "S", deserialize_with = "de_quote_subscription_id")]
53 pub subscription_id: SubscriptionId,
54 #[serde(rename = "bp")]
55 pub bid_price: Decimal,
56 #[serde(rename = "bs")]
57 pub bid_size: Decimal,
58 #[serde(rename = "ap")]
59 pub ask_price: Decimal,
60 #[serde(rename = "as")]
61 pub ask_size: Decimal,
62 #[serde(rename = "t")]
63 pub timestamp: DateTime<Utc>,
64 #[serde(rename = "bx", default)]
65 pub bid_exchange: Option<SmolStr>,
66 #[serde(rename = "ax", default)]
67 pub ask_exchange: Option<SmolStr>,
68 #[serde(rename = "z", default)]
69 pub tape: Option<SmolStr>,
70}
71
72#[derive(Debug)]
77pub struct AlpacaQuoteMessage(Vec<AlpacaQuote>);
78
79#[derive(Deserialize)]
83#[serde(tag = "T")]
84enum AlpacaArrayQuoteMsg {
85 #[serde(rename = "q")]
86 Quote(AlpacaQuote),
87 #[serde(other)]
88 Other,
89}
90
91impl<'de> Deserialize<'de> for AlpacaQuoteMessage {
92 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93 where
94 D: Deserializer<'de>,
95 {
96 let messages = Vec::<AlpacaArrayQuoteMsg>::deserialize(deserializer)?;
97 let mut quotes = Vec::with_capacity(messages.len());
98 for msg in messages {
99 if let AlpacaArrayQuoteMsg::Quote(quote) = msg {
100 quotes.push(quote);
101 }
102 }
103 Ok(AlpacaQuoteMessage(quotes))
104 }
105}
106
107impl From<AlpacaQuote> for Quote {
108 fn from(quote: AlpacaQuote) -> Self {
109 Self {
110 bid_price: quote.bid_price,
111 bid_amount: quote.bid_size,
112 ask_price: quote.ask_price,
113 ask_amount: quote.ask_size,
114 }
115 }
116}
117
118impl From<AlpacaQuote> for OrderBookL1 {
130 fn from(quote: AlpacaQuote) -> Self {
131 Self {
132 last_update_time: quote.timestamp,
135 best_bid: quoted(quote.bid_price, quote.bid_size),
136 best_ask: quoted(quote.ask_price, quote.ask_size),
137 }
138 }
139}
140
141fn quoted(price: Decimal, size: Decimal) -> Option<Level> {
143 (!price.is_zero()).then(|| Level::new(price, size))
144}
145
146#[derive(Debug)]
153pub struct AlpacaQuoteTransformer<Exchange, InstrumentKey, Kind = Quotes> {
154 instrument_map: Map<InstrumentKey>,
155 exchange_id: ExchangeId,
156 phantom: PhantomData<(Exchange, Kind)>,
157}
158
159impl<Exchange, InstrumentKey, Kind> ExchangeTransformer<Exchange, InstrumentKey, Kind>
160 for AlpacaQuoteTransformer<Exchange, InstrumentKey, Kind>
161where
162 Exchange: crate::exchange::Connector + Send,
163 InstrumentKey: Clone + Send + Sync,
164 Kind: SubscriptionKind + Send,
165 Kind::Event: From<AlpacaQuote>,
166{
167 async fn init(
168 instrument_map: Map<InstrumentKey>,
169 _: &[MarketEvent<InstrumentKey, Kind::Event>],
170 _: mpsc::UnboundedSender<WsMessage>,
171 ) -> Result<Self, DataError> {
172 Ok(Self {
173 instrument_map,
174 exchange_id: Exchange::ID,
175 phantom: PhantomData,
176 })
177 }
178}
179
180impl<Exchange, InstrumentKey, Kind> Transformer
181 for AlpacaQuoteTransformer<Exchange, InstrumentKey, Kind>
182where
183 Exchange: crate::exchange::Connector,
184 InstrumentKey: Clone,
185 Kind: SubscriptionKind,
186 Kind::Event: From<AlpacaQuote>,
187{
188 type Error = DataError;
189 type Input = AlpacaQuoteMessage;
190 type Output = MarketEvent<InstrumentKey, Kind::Event>;
191 type OutputIter = Vec<Result<Self::Output, Self::Error>>;
192
193 fn transform(&mut self, input: Self::Input) -> Self::OutputIter {
194 let mut results = Vec::with_capacity(input.0.len());
195 let time_received = Utc::now();
196
197 for quote in input.0 {
198 match self.instrument_map.find("e.subscription_id) {
199 Ok(instrument) => {
200 results.push(Ok(MarketEvent {
201 time_exchange: quote.timestamp,
202 time_received,
203 exchange: self.exchange_id,
204 instrument: instrument.clone(),
205 kind: Kind::Event::from(quote),
206 }));
207 }
208 Err(unidentified) => {
209 results.push(Err(DataError::Socket(unidentified.to_string())));
210 }
211 }
212 }
213
214 results
215 }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used)] mod tests {
221 use super::*;
222 use rust_decimal_macros::dec;
223
224 #[test]
225 fn test_de_equities_quote() {
226 let input = r#"{"T":"q","S":"AAPL","ax":"V","ap":150.25,"as":100,"bx":"Q","bp":150.20,"bs":200,"c":["R"],"z":"C","t":"2026-05-02T14:00:00Z"}"#;
227 let quote: AlpacaQuote = serde_json::from_str(input).unwrap();
228
229 assert_eq!(quote.subscription_id.as_ref(), "quotes|AAPL");
230 assert_eq!(quote.bid_price, dec!(150.20));
231 assert_eq!(quote.bid_size, dec!(200));
232 assert_eq!(quote.ask_price, dec!(150.25));
233 assert_eq!(quote.ask_size, dec!(100));
234 assert_eq!(quote.bid_exchange, Some(SmolStr::new("Q")));
235 assert_eq!(quote.ask_exchange, Some(SmolStr::new("V")));
236 assert_eq!(quote.tape, Some(SmolStr::new("C")));
237 }
238
239 #[test]
240 fn test_de_crypto_quote() {
241 let input = r#"{"T":"q","S":"BTC/USD","ap":60000.50,"as":1.0,"bp":60000.00,"bs":2.0,"t":"2026-05-02T14:00:00Z"}"#;
242 let quote: AlpacaQuote = serde_json::from_str(input).unwrap();
243
244 assert_eq!(quote.subscription_id.as_ref(), "quotes|BTC/USD");
245 assert_eq!(quote.bid_price, dec!(60000.00));
246 assert_eq!(quote.bid_size, dec!(2.0));
247 assert_eq!(quote.ask_price, dec!(60000.50));
248 assert_eq!(quote.ask_size, dec!(1.0));
249 assert!(quote.bid_exchange.is_none());
250 assert!(quote.ask_exchange.is_none());
251 assert!(quote.tape.is_none());
252 }
253
254 #[test]
255 fn test_subscription_id() {
256 let input = r#"{"T":"q","S":"SPY","bp":450.0,"bs":100,"ap":450.05,"as":50,"t":"2026-05-02T14:00:00Z"}"#;
257 let quote: AlpacaQuote = serde_json::from_str(input).unwrap();
258 assert_eq!(quote.subscription_id.as_ref(), "quotes|SPY");
259 }
260
261 #[test]
262 fn a_quote_becomes_a_book_stamped_with_its_own_time() {
263 let input = r#"{"T":"q","S":"BTC/USD","ap":60000.50,"as":1.0,"bp":60000.00,"bs":2.0,"t":"2026-05-02T14:00:00Z"}"#;
264 let quote: AlpacaQuote = serde_json::from_str(input).unwrap();
265 let time = quote.timestamp;
266
267 let book = OrderBookL1::from(quote);
268
269 assert_eq!(book.last_update_time, time);
270 assert_eq!(book.best_bid, Some(Level::new(dec!(60000.00), dec!(2.0))));
271 assert_eq!(book.best_ask, Some(Level::new(dec!(60000.50), dec!(1.0))));
272 }
273
274 #[test]
275 fn a_zero_priced_side_is_absent_but_a_zero_sized_one_is_kept() {
276 let input =
277 r#"{"T":"q","S":"AAPL","bp":0,"bs":0,"ap":150.25,"as":0,"t":"2026-05-02T14:00:00Z"}"#;
278 let quote: AlpacaQuote = serde_json::from_str(input).unwrap();
279
280 let book = OrderBookL1::from(quote);
281
282 assert_eq!(book.best_bid, None);
283 assert_eq!(book.best_ask, Some(Level::new(dec!(150.25), dec!(0))));
284 }
285
286 #[test]
287 fn each_quote_in_a_frame_becomes_one_book_under_its_own_instrument() {
288 use crate::{exchange::alpaca::AlpacaCrypto, subscription::book::OrderBooksL1};
289 use rustrade_integration::subscription::SubscriptionId;
290
291 let map = Map([
292 (SubscriptionId::from("quotes|BTC/USD"), 1_u8),
293 (SubscriptionId::from("quotes|ETH/USD"), 2_u8),
294 ]
295 .into_iter()
296 .collect());
297 let mut transformer = AlpacaQuoteTransformer::<AlpacaCrypto, u8, OrderBooksL1> {
298 instrument_map: map,
299 exchange_id: ExchangeId::AlpacaCrypto,
300 phantom: PhantomData,
301 };
302 let frame: AlpacaQuoteMessage = serde_json::from_str(
303 r#"[{"T":"q","S":"BTC/USD","ap":2,"as":1,"bp":1,"bs":1,"t":"2026-05-02T14:00:00Z"},
304 {"T":"q","S":"ETH/USD","ap":4,"as":1,"bp":3,"bs":1,"t":"2026-05-02T14:00:01Z"}]"#,
305 )
306 .unwrap();
307
308 let events = transformer
309 .transform(frame)
310 .into_iter()
311 .map(Result::unwrap)
312 .collect::<Vec<_>>();
313
314 assert_eq!(events.len(), 2);
315 for (event, (instrument, bid)) in events.iter().zip([(1, dec!(1)), (2, dec!(3))]) {
316 assert_eq!(event.exchange, ExchangeId::AlpacaCrypto);
317 assert_eq!(event.instrument, instrument);
318 assert_eq!(event.kind.last_update_time, event.time_exchange);
319 assert_eq!(event.kind.best_bid.unwrap().price, bid);
320 }
321 }
322}