Skip to main content

rustyschwab_types/streaming/
payload.rs

1//! Field tables and typed payloads for the streamer data services.
2//!
3//! The streamer sends every service's fields under numeric keys whose
4//! meaning depends on the service: `"1"` is the bid price of an equity but
5//! the description of an option. Each data service has a `*Fields` table
6//! here — the constants used to build subscription field lists and the
7//! number → name map — plus a payload struct the named fields deserialize
8//! into. Field 0 is always the subscription key and arrives as `key`.
9//!
10//! Names follow the REST types for the same concept
11//! ([`QuoteQuote`](crate::market_data::QuoteQuote),
12//! [`Candle`](crate::market_data::Candle)) so a stream delta and a REST
13//! quote merge by name; prices are `f64` for the same reason, and because
14//! nothing is lost by it: Schwab emits JSON numbers, `serde_json` parses
15//! those to `f64` before any `Decimal` impl sees them, and `Decimal`'s own
16//! `visit_f64` re-parses the shortest round-trip string. Convert at the
17//! point a price enters exact accounting (`value.to_string().parse()`, as
18//! the paper feed does) rather than in the wire type. Requests go the other
19//! way: [`OrderRequest`](crate::trading::OrderRequest) prices are `Decimal`
20//! serialized as strings so the broker never sees a float.
21//!
22//! ```
23//! use rustyschwab_types::streaming::{DataContent, LevelOneEquity, LevelOneEquityFields};
24//!
25//! let content: DataContent = serde_json::from_value(serde_json::json!({
26//!     "key": "AMD", "delayed": false, "assetMainType": "EQUITY",
27//!     "1": 217.86, "2": 217.95, "8": 33292396
28//! }))
29//! .unwrap();
30//!
31//! let quote = LevelOneEquity::from_content(&content).unwrap();
32//! assert_eq!(quote.bid_price, Some(217.86));
33//! assert_eq!(quote.total_volume, Some(33292396));
34//! assert_eq!(LevelOneEquityFields::name(LevelOneEquityFields::BID_PRICE), Some("bidPrice"));
35//! ```
36
37use super::{DataContent, DataItem, StreamService};
38use crate::common::{AssetMainType, AssetSubType};
39use serde::{Deserialize, Serialize};
40use serde_json::{Map, Value};
41use std::collections::HashMap;
42
43/// One row of a service's field table.
44#[derive(Debug, Clone, Copy)]
45#[non_exhaustive]
46pub struct Field {
47    /// The wire field number.
48    pub number: u32,
49    /// The name the field decodes to.
50    pub name: &'static str,
51    /// The table for the elements when the value is an array of numbered
52    /// objects (book price levels).
53    pub nested: Option<&'static [Field]>,
54}
55
56impl Field {
57    const fn new(number: u32, name: &'static str) -> Self {
58        Self {
59            number,
60            name,
61            nested: None,
62        }
63    }
64
65    const fn nested(number: u32, name: &'static str, table: &'static [Field]) -> Self {
66        Self {
67            number,
68            name,
69            nested: Some(table),
70        }
71    }
72}
73
74/// Field numbers as the comma-separated list a subscription request takes.
75pub(super) fn csv(numbers: impl Iterator<Item = u32>) -> String {
76    numbers
77        .map(|number| number.to_string())
78        .collect::<Vec<_>>()
79        .join(",")
80}
81
82/// The table entry for wire key `key`, if it is a field number the table
83/// lists. Tables are in field order and mostly contiguous from 0, so the
84/// index is tried before a scan.
85fn lookup<'t>(key: &str, table: &'t [Field]) -> Option<&'t Field> {
86    let number = key.parse::<u32>().ok()?;
87    table
88        .get(number as usize)
89        .filter(|field| field.number == number)
90        .or_else(|| table.iter().find(|field| field.number == number))
91}
92
93/// Renames the numeric keys of `entries` per `table`; keys the table does
94/// not list, and non-numeric keys, pass through unchanged.
95fn rename(
96    entries: impl IntoIterator<Item = (String, Value)>,
97    table: &[Field],
98) -> Map<String, Value> {
99    entries
100        .into_iter()
101        .map(|(key, value)| match lookup(&key, table) {
102            Some(field) => {
103                let value = match field.nested {
104                    Some(nested) => rename_nested(value, nested),
105                    None => value,
106                };
107                (field.name.to_owned(), value)
108            }
109            None => (key, value),
110        })
111        .collect()
112}
113
114fn rename_nested(value: Value, table: &[Field]) -> Value {
115    match value {
116        Value::Array(items) => Value::Array(
117            items
118                .into_iter()
119                .map(|item| rename_nested(item, table))
120                .collect(),
121        ),
122        Value::Object(map) => Value::Object(rename(map, table)),
123        other => other,
124    }
125}
126
127impl DataContent {
128    /// The numbered fields renamed per `table` (see [`StreamService::fields`]).
129    /// Numbers the table does not list, and non-numeric keys such as `seq`,
130    /// pass through unchanged.
131    pub fn named_fields(&self, table: &[Field]) -> Map<String, Value> {
132        rename(
133            self.fields.iter().map(|(k, v)| (k.clone(), v.clone())),
134            table,
135        )
136    }
137
138    /// The whole item as one JSON object: the envelope (`key`, `delayed`,
139    /// `assetMainType`, …) plus [`named_fields`](Self::named_fields). This is
140    /// the shape the typed payloads deserialize from.
141    pub fn to_named_value(&self, table: &[Field]) -> Value {
142        self.clone().into_named_value(table)
143    }
144
145    /// [`to_named_value`](Self::to_named_value) without copying: for
146    /// consumers that own the item and do not need it afterwards.
147    pub fn into_named_value(self, table: &[Field]) -> Value {
148        let mut map = rename(self.fields, table);
149        map.insert("key".into(), Value::String(self.key));
150        if let Some(delayed) = self.delayed {
151            map.insert("delayed".into(), Value::Bool(delayed));
152        }
153        if let Some(Ok(asset)) = self.asset_main_type.map(serde_json::to_value) {
154            map.insert("assetMainType".into(), asset);
155        }
156        if let Some(Ok(asset)) = self.asset_sub_type.map(serde_json::to_value) {
157            map.insert("assetSubType".into(), asset);
158        }
159        if let Some(cusip) = self.cusip {
160            map.insert("cusip".into(), Value::String(cusip));
161        }
162        Value::Object(map)
163    }
164}
165
166/// Declares one service's field table and typed payload from a single row
167/// list: `number CONSTANT field_name "wireName": Type`.
168///
169/// `key` names the field-0 constant. `envelope` lists the non-numbered
170/// keys the service sends beside `key`. `default` (optional) narrows
171/// `default_fields()` from every field to the listed constants. A row
172/// suffixed `[Record]` holds an array of numbered objects described by
173/// `Record::FIELDS`.
174macro_rules! field_table {
175    (
176        $(#[$doc:meta])*
177        $table:ident => $payload:ident {
178            key = $key:ident;
179            envelope = { $( $env:ident : $env_ty:ty ),* $(,)? };
180            $( default = [ $( $default:ident ),* $(,)? ]; )?
181            $(
182                $(#[$field_doc:meta])*
183                $number:literal $konst:ident $field:ident $name:literal : $ty:ty
184                $([ $nested:ident ])? ,
185            )*
186        }
187    ) => {
188        #[doc = concat!("Field numbers and names for [`", stringify!($payload), "`].")]
189        #[derive(Debug, Clone, Copy)]
190        pub struct $table;
191
192        impl $table {
193            /// Field 0: the subscription key, delivered as `key`.
194            pub const $key: u32 = 0;
195            $( pub const $konst: u32 = $number; )*
196
197            /// The table, in field order.
198            pub const FIELDS: &'static [Field] = &[
199                Field::new(0, "key"),
200                $( field_table!(@field $number $name $($nested)?), )*
201            ];
202
203            /// The name field `number` decodes to.
204            pub fn name(number: u32) -> Option<&'static str> {
205                Self::FIELDS
206                    .iter()
207                    .find(|field| field.number == number)
208                    .map(|field| field.name)
209            }
210
211            /// Every field number, comma-separated, for a subscription request.
212            pub fn all_fields() -> String {
213                csv(Self::FIELDS.iter().map(|field| field.number))
214            }
215
216            $(
217                /// The fields subscribed by default: a quote-sized subset of
218                /// [`all_fields`](Self::all_fields).
219                pub fn default_fields() -> String {
220                    csv([$( Self::$default ),*].into_iter())
221                }
222            )?
223        }
224
225        $(#[$doc])*
226        #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
227        #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
228        #[serde(rename_all = "camelCase")]
229        pub struct $payload {
230            /// The subscription key (field 0).
231            pub key: String,
232            $(
233                #[serde(default, skip_serializing_if = "Option::is_none")]
234                pub $env: Option<$env_ty>,
235            )*
236            $(
237                $(#[$field_doc])*
238                #[serde(rename = $name, default, skip_serializing_if = "Option::is_none")]
239                pub $field: Option<$ty>,
240            )*
241            /// Fields the table does not describe, by wire key.
242            #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
243            pub extra: HashMap<String, Value>,
244        }
245
246        impl $payload {
247            #[doc = concat!(
248                "Decode a content item using [`", stringify!($table), "::FIELDS`]."
249            )]
250            pub fn from_content(content: &DataContent) -> Result<Self, serde_json::Error> {
251                serde_json::from_value(content.to_named_value($table::FIELDS))
252            }
253        }
254    };
255    (@field $number:literal $name:literal) => {
256        Field::new($number, $name)
257    };
258    (@field $number:literal $name:literal $nested:ident) => {
259        Field::nested($number, $name, $nested::FIELDS)
260    };
261}
262
263field_table! {
264    /// A `LEVELONE_EQUITIES` quote. Deltas carry only the changed fields.
265    LevelOneEquityFields => LevelOneEquity {
266        key = SYMBOL;
267        envelope = {
268            delayed: bool,
269            asset_main_type: AssetMainType,
270            asset_sub_type: AssetSubType,
271            cusip: String,
272        };
273        default = [
274            SYMBOL, BID_PRICE, ASK_PRICE, LAST_PRICE, BID_SIZE, ASK_SIZE, TOTAL_VOLUME,
275            LAST_SIZE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, OPEN_PRICE, NET_CHANGE,
276            NET_CHANGE_PERCENT, QUOTE_TIME_MILLIS, TRADE_TIME_MILLIS,
277        ];
278        1 BID_PRICE bid_price "bidPrice": f64,
279        2 ASK_PRICE ask_price "askPrice": f64,
280        3 LAST_PRICE last_price "lastPrice": f64,
281        4 BID_SIZE bid_size "bidSize": i64,
282        5 ASK_SIZE ask_size "askSize": i64,
283        /// Exchange code of the ask.
284        6 ASK_ID ask_id "askId": String,
285        /// Exchange code of the bid.
286        7 BID_ID bid_id "bidId": String,
287        8 TOTAL_VOLUME total_volume "totalVolume": i64,
288        9 LAST_SIZE last_size "lastSize": i64,
289        10 HIGH_PRICE high_price "highPrice": f64,
290        11 LOW_PRICE low_price "lowPrice": f64,
291        /// Previous day's close.
292        12 CLOSE_PRICE close_price "closePrice": f64,
293        /// Primary listing exchange code.
294        13 EXCHANGE_ID exchange_id "exchangeId": String,
295        14 MARGINABLE marginable "marginable": bool,
296        15 DESCRIPTION description "description": String,
297        /// Exchange code of the last trade.
298        16 LAST_ID last_id "lastId": String,
299        17 OPEN_PRICE open_price "openPrice": f64,
300        18 NET_CHANGE net_change "netChange": f64,
301        19 HIGH_52_WEEK fifty_two_week_high "52WeekHigh": f64,
302        20 LOW_52_WEEK fifty_two_week_low "52WeekLow": f64,
303        21 PE_RATIO pe_ratio "peRatio": f64,
304        /// Annual dividend amount.
305        22 DIV_AMOUNT div_amount "divAmount": f64,
306        23 DIV_YIELD div_yield "divYield": f64,
307        /// Mutual fund net asset value.
308        24 NAV nav "nav": f64,
309        25 EXCHANGE_NAME exchange_name "exchangeName": String,
310        26 DIV_DATE div_date "divDate": String,
311        /// Whether the last quote came from the regular session.
312        27 IS_REGULAR_MARKET_QUOTE regular_market_quote "regularMarketQuote": bool,
313        /// Whether the last trade came from the regular session.
314        28 IS_REGULAR_MARKET_TRADE regular_market_trade "regularMarketTrade": bool,
315        29 REGULAR_MARKET_LAST_PRICE regular_market_last_price "regularMarketLastPrice": f64,
316        30 REGULAR_MARKET_LAST_SIZE regular_market_last_size "regularMarketLastSize": i64,
317        31 REGULAR_MARKET_NET_CHANGE regular_market_net_change "regularMarketNetChange": f64,
318        32 SECURITY_STATUS security_status "securityStatus": String,
319        33 MARK mark "mark": f64,
320        /// Epoch milliseconds.
321        34 QUOTE_TIME_MILLIS quote_time "quoteTime": i64,
322        /// Epoch milliseconds.
323        35 TRADE_TIME_MILLIS trade_time "tradeTime": i64,
324        /// Epoch milliseconds.
325        36 REGULAR_MARKET_TRADE_MILLIS regular_market_trade_time "regularMarketTradeTime": i64,
326        /// Epoch milliseconds.
327        37 BID_TIME_MILLIS bid_time "bidTime": i64,
328        /// Epoch milliseconds.
329        38 ASK_TIME_MILLIS ask_time "askTime": i64,
330        39 ASK_MIC_ID ask_mic_id "askMICId": String,
331        40 BID_MIC_ID bid_mic_id "bidMICId": String,
332        41 LAST_MIC_ID last_mic_id "lastMICId": String,
333        42 NET_CHANGE_PERCENT net_percent_change "netPercentChange": f64,
334        43 REGULAR_MARKET_CHANGE_PERCENT regular_market_percent_change
335            "regularMarketPercentChange": f64,
336        44 MARK_CHANGE mark_change "markChange": f64,
337        45 MARK_CHANGE_PERCENT mark_percent_change "markPercentChange": f64,
338        46 HTB_QUANTITY htb_quantity "htbQuantity": i64,
339        47 HTB_RATE htb_rate "htbRate": f64,
340        /// `-1` unknown, `0` no, `1` yes.
341        48 HARD_TO_BORROW hard_to_borrow "hardToBorrow": i64,
342        /// `-1` unknown, `0` no, `1` yes.
343        49 IS_SHORTABLE shortable "shortable": i64,
344        50 POST_MARKET_NET_CHANGE post_market_change "postMarketChange": f64,
345        51 POST_MARKET_CHANGE_PERCENT post_market_percent_change "postMarketPercentChange": f64,
346    }
347}
348
349field_table! {
350    /// A `LEVELONE_OPTIONS` quote. Deltas carry only the changed fields.
351    LevelOneOptionFields => LevelOneOption {
352        key = SYMBOL;
353        envelope = { delayed: bool, asset_main_type: AssetMainType };
354        1 DESCRIPTION description "description": String,
355        2 BID_PRICE bid_price "bidPrice": f64,
356        3 ASK_PRICE ask_price "askPrice": f64,
357        4 LAST_PRICE last_price "lastPrice": f64,
358        5 HIGH_PRICE high_price "highPrice": f64,
359        6 LOW_PRICE low_price "lowPrice": f64,
360        /// Previous day's close.
361        7 CLOSE_PRICE close_price "closePrice": f64,
362        8 TOTAL_VOLUME total_volume "totalVolume": i64,
363        9 OPEN_INTEREST open_interest "openInterest": i64,
364        10 VOLATILITY volatility "volatility": f64,
365        11 INTRINSIC_VALUE money_intrinsic_value "moneyIntrinsicValue": f64,
366        12 EXP_YEAR expiration_year "expirationYear": i64,
367        13 MULTIPLIER multiplier "multiplier": f64,
368        /// Decimal places in the price.
369        14 DIGITS digits "digits": i64,
370        15 OPEN_PRICE open_price "openPrice": f64,
371        16 BID_SIZE bid_size "bidSize": i64,
372        17 ASK_SIZE ask_size "askSize": i64,
373        18 LAST_SIZE last_size "lastSize": i64,
374        19 NET_CHANGE net_change "netChange": f64,
375        20 STRIKE_PRICE strike_price "strikePrice": f64,
376        /// `C` or `P`.
377        21 CONTRACT_TYPE contract_type "contractType": String,
378        22 UNDERLYING underlying "underlying": String,
379        23 EXP_MONTH expiration_month "expirationMonth": i64,
380        24 DELIVERABLES deliverables "deliverables": String,
381        25 TIME_VALUE time_value "timeValue": f64,
382        26 EXP_DAY expiration_day "expirationDay": i64,
383        27 DAYS_TO_EXP days_to_expiration "daysToExpiration": i64,
384        28 DELTA delta "delta": f64,
385        29 GAMMA gamma "gamma": f64,
386        30 THETA theta "theta": f64,
387        31 VEGA vega "vega": f64,
388        32 RHO rho "rho": f64,
389        33 SECURITY_STATUS security_status "securityStatus": String,
390        34 THEORETICAL_VALUE theoretical_option_value "theoreticalOptionValue": f64,
391        35 UNDERLYING_PRICE underlying_price "underlyingPrice": f64,
392        36 UV_EXPIRATION_TYPE uv_expiration_type "uvExpirationType": String,
393        37 MARK mark "mark": f64,
394        /// Epoch milliseconds.
395        38 QUOTE_TIME_MILLIS quote_time "quoteTime": i64,
396        /// Epoch milliseconds.
397        39 TRADE_TIME_MILLIS trade_time "tradeTime": i64,
398        40 EXCHANGE exchange "exchange": String,
399        41 EXCHANGE_NAME exchange_name "exchangeName": String,
400        /// Epoch milliseconds.
401        42 LAST_TRADING_DAY last_trading_day "lastTradingDay": i64,
402        43 SETTLEMENT_TYPE settlement_type "settlementType": String,
403        44 NET_PERCENT_CHANGE net_percent_change "netPercentChange": f64,
404        45 MARK_NET_CHANGE mark_change "markChange": f64,
405        46 MARK_PERCENT_CHANGE mark_percent_change "markPercentChange": f64,
406        47 IMPLIED_YIELD implied_yield "impliedYield": f64,
407        48 IS_PENNY_PILOT is_penny_pilot "isPennyPilot": bool,
408        49 OPTION_ROOT option_root "optionRoot": String,
409        50 HIGH_52_WEEK fifty_two_week_high "52WeekHigh": f64,
410        51 LOW_52_WEEK fifty_two_week_low "52WeekLow": f64,
411        52 INDICATIVE_ASK_PRICE indicative_ask_price "indAskPrice": f64,
412        53 INDICATIVE_BID_PRICE indicative_bid_price "indBidPrice": f64,
413        /// Epoch milliseconds.
414        54 INDICATIVE_QUOTE_TIME indicative_quote_time "indQuoteTime": i64,
415        55 EXERCISE_TYPE exercise_type "exerciseType": String,
416    }
417}
418
419field_table! {
420    /// A `LEVELONE_FUTURES` quote. Deltas carry only the changed fields.
421    LevelOneFuturesFields => LevelOneFutures {
422        key = SYMBOL;
423        envelope = { delayed: bool, asset_main_type: AssetMainType };
424        1 BID_PRICE bid_price "bidPrice": f64,
425        2 ASK_PRICE ask_price "askPrice": f64,
426        3 LAST_PRICE last_price "lastPrice": f64,
427        4 BID_SIZE bid_size "bidSize": i64,
428        5 ASK_SIZE ask_size "askSize": i64,
429        /// Exchange code of the bid.
430        6 BID_ID bid_id "bidId": String,
431        /// Exchange code of the ask.
432        7 ASK_ID ask_id "askId": String,
433        8 TOTAL_VOLUME total_volume "totalVolume": f64,
434        9 LAST_SIZE last_size "lastSize": i64,
435        /// Epoch milliseconds.
436        10 QUOTE_TIME quote_time "quoteTime": i64,
437        /// Epoch milliseconds.
438        11 TRADE_TIME trade_time "tradeTime": i64,
439        12 HIGH_PRICE high_price "highPrice": f64,
440        13 LOW_PRICE low_price "lowPrice": f64,
441        /// Previous day's close.
442        14 CLOSE_PRICE close_price "closePrice": f64,
443        15 EXCHANGE_ID exchange_id "exchangeId": String,
444        16 DESCRIPTION description "description": String,
445        /// Exchange code of the last trade.
446        17 LAST_ID last_id "lastId": String,
447        18 OPEN_PRICE open_price "openPrice": f64,
448        19 NET_CHANGE net_change "netChange": f64,
449        20 FUTURE_PERCENT_CHANGE future_percent_change "futurePercentChange": f64,
450        21 EXCHANGE_NAME exchange_name "exchangeName": String,
451        22 SECURITY_STATUS security_status "securityStatus": String,
452        23 OPEN_INTEREST open_interest "openInterest": i64,
453        24 MARK mark "mark": f64,
454        /// Minimum price movement.
455        25 TICK tick "tick": f64,
456        /// Money value of one tick.
457        26 TICK_AMOUNT tick_amount "tickAmount": f64,
458        27 PRODUCT product "product": String,
459        28 FUTURE_PRICE_FORMAT future_price_format "futurePriceFormat": String,
460        29 FUTURE_TRADING_HOURS future_trading_hours "futureTradingHours": String,
461        30 FUTURE_IS_TRADABLE future_is_tradable "futureIsTradable": bool,
462        31 FUTURE_MULTIPLIER future_multiplier "futureMultiplier": f64,
463        32 FUTURE_IS_ACTIVE future_is_active "futureIsActive": bool,
464        33 FUTURE_SETTLEMENT_PRICE future_settlement_price "futureSettlementPrice": f64,
465        34 FUTURE_ACTIVE_SYMBOL future_active_symbol "futureActiveSymbol": String,
466        /// Epoch milliseconds.
467        35 FUTURE_EXPIRATION_DATE future_expiration_date "futureExpirationDate": i64,
468        36 EXPIRATION_STYLE expiration_style "expirationStyle": String,
469        /// Epoch milliseconds.
470        37 ASK_TIME ask_time "askTime": i64,
471        /// Epoch milliseconds.
472        38 BID_TIME bid_time "bidTime": i64,
473        39 QUOTED_IN_SESSION quoted_in_session "quotedInSession": bool,
474        /// Epoch milliseconds.
475        40 SETTLEMENT_DATE settlement_date "settlementDate": i64,
476    }
477}
478
479field_table! {
480    /// A `LEVELONE_FUTURES_OPTIONS` quote. Deltas carry only the changed fields.
481    LevelOneFuturesOptionFields => LevelOneFuturesOption {
482        key = SYMBOL;
483        envelope = { delayed: bool, asset_main_type: AssetMainType };
484        1 BID_PRICE bid_price "bidPrice": f64,
485        2 ASK_PRICE ask_price "askPrice": f64,
486        3 LAST_PRICE last_price "lastPrice": f64,
487        4 BID_SIZE bid_size "bidSize": i64,
488        5 ASK_SIZE ask_size "askSize": i64,
489        /// Exchange code of the bid.
490        6 BID_ID bid_id "bidId": String,
491        /// Exchange code of the ask.
492        7 ASK_ID ask_id "askId": String,
493        8 TOTAL_VOLUME total_volume "totalVolume": i64,
494        9 LAST_SIZE last_size "lastSize": i64,
495        /// Epoch milliseconds.
496        10 QUOTE_TIME quote_time "quoteTime": i64,
497        /// Epoch milliseconds.
498        11 TRADE_TIME trade_time "tradeTime": i64,
499        12 HIGH_PRICE high_price "highPrice": f64,
500        13 LOW_PRICE low_price "lowPrice": f64,
501        /// Previous day's close.
502        14 CLOSE_PRICE close_price "closePrice": f64,
503        /// Exchange code of the last trade.
504        15 LAST_ID last_id "lastId": String,
505        16 DESCRIPTION description "description": String,
506        17 OPEN_PRICE open_price "openPrice": f64,
507        18 OPEN_INTEREST open_interest "openInterest": f64,
508        19 MARK mark "mark": f64,
509        /// Minimum price movement.
510        20 TICK tick "tick": f64,
511        /// Money value of one tick.
512        21 TICK_AMOUNT tick_amount "tickAmount": f64,
513        22 FUTURE_MULTIPLIER future_multiplier "futureMultiplier": f64,
514        23 FUTURE_SETTLEMENT_PRICE future_settlement_price "futureSettlementPrice": f64,
515        24 UNDERLYING_SYMBOL underlying_symbol "underlyingSymbol": String,
516        25 STRIKE_PRICE strike_price "strikePrice": f64,
517        /// Epoch milliseconds.
518        26 FUTURE_EXPIRATION_DATE future_expiration_date "futureExpirationDate": i64,
519        27 EXPIRATION_STYLE expiration_style "expirationStyle": String,
520        /// `C` or `P`.
521        28 CONTRACT_TYPE contract_type "contractType": String,
522        29 SECURITY_STATUS security_status "securityStatus": String,
523        30 EXCHANGE exchange "exchange": String,
524        31 EXCHANGE_NAME exchange_name "exchangeName": String,
525    }
526}
527
528field_table! {
529    /// A `LEVELONE_FOREX` quote. Deltas carry only the changed fields.
530    LevelOneForexFields => LevelOneForex {
531        key = SYMBOL;
532        envelope = { delayed: bool, asset_main_type: AssetMainType };
533        1 BID_PRICE bid_price "bidPrice": f64,
534        2 ASK_PRICE ask_price "askPrice": f64,
535        3 LAST_PRICE last_price "lastPrice": f64,
536        4 BID_SIZE bid_size "bidSize": i64,
537        5 ASK_SIZE ask_size "askSize": i64,
538        6 TOTAL_VOLUME total_volume "totalVolume": f64,
539        7 LAST_SIZE last_size "lastSize": i64,
540        /// Epoch milliseconds.
541        8 QUOTE_TIME quote_time "quoteTime": i64,
542        /// Epoch milliseconds.
543        9 TRADE_TIME trade_time "tradeTime": i64,
544        10 HIGH_PRICE high_price "highPrice": f64,
545        11 LOW_PRICE low_price "lowPrice": f64,
546        /// Previous day's close.
547        12 CLOSE_PRICE close_price "closePrice": f64,
548        13 EXCHANGE exchange "exchange": String,
549        14 DESCRIPTION description "description": String,
550        15 OPEN_PRICE open_price "openPrice": f64,
551        16 NET_CHANGE net_change "netChange": f64,
552        17 PERCENT_CHANGE percent_change "percentChange": f64,
553        18 EXCHANGE_NAME exchange_name "exchangeName": String,
554        /// Decimal places in the price.
555        19 DIGITS digits "digits": i64,
556        20 SECURITY_STATUS security_status "securityStatus": String,
557        /// Minimum price movement.
558        21 TICK tick "tick": f64,
559        /// Money value of one tick.
560        22 TICK_AMOUNT tick_amount "tickAmount": f64,
561        23 PRODUCT product "product": String,
562        24 TRADING_HOURS trading_hours "tradingHours": String,
563        25 IS_TRADABLE is_tradable "isTradable": bool,
564        26 MARKET_MAKER market_maker "marketMaker": String,
565        27 HIGH_52_WEEK fifty_two_week_high "52WeekHigh": f64,
566        28 LOW_52_WEEK fifty_two_week_low "52WeekLow": f64,
567        29 MARK mark "mark": f64,
568    }
569}
570
571/// One market maker's quote inside a [`BookLevel`].
572#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
573#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
574#[serde(rename_all = "camelCase")]
575#[non_exhaustive]
576pub struct BookMarketMaker {
577    pub market_maker_id: String,
578    pub size: i64,
579    /// As sent by the venue; not an epoch timestamp.
580    pub quote_time: i64,
581}
582
583impl BookMarketMaker {
584    /// Field numbers and names of the numbered object on the wire.
585    pub const FIELDS: &'static [Field] = &[
586        Field::new(0, "marketMakerId"),
587        Field::new(1, "size"),
588        Field::new(2, "quoteTime"),
589    ];
590}
591
592/// One price level of a [`Book`] side.
593#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
594#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
595#[serde(rename_all = "camelCase")]
596#[non_exhaustive]
597pub struct BookLevel {
598    pub price: f64,
599    pub aggregate_size: i64,
600    pub market_maker_count: i64,
601    #[serde(default)]
602    pub market_makers: Vec<BookMarketMaker>,
603}
604
605impl BookLevel {
606    /// Field numbers and names of the numbered object on the wire.
607    pub const FIELDS: &'static [Field] = &[
608        Field::new(0, "price"),
609        Field::new(1, "aggregateSize"),
610        Field::new(2, "marketMakerCount"),
611        Field::nested(3, "marketMakers", BookMarketMaker::FIELDS),
612    ];
613}
614
615field_table! {
616    /// A `NYSE_BOOK`, `NASDAQ_BOOK` or `OPTIONS_BOOK` depth snapshot. Every
617    /// frame carries the whole book.
618    BookFields => Book {
619        key = SYMBOL;
620        envelope = {};
621        /// Epoch milliseconds.
622        1 MARKET_SNAPSHOT_TIME market_snapshot_time "marketSnapshotTime": i64,
623        2 BID_SIDE_LEVELS bid_side_levels "bidSideLevels": Vec<BookLevel> [BookLevel],
624        3 ASK_SIDE_LEVELS ask_side_levels "askSideLevels": Vec<BookLevel> [BookLevel],
625    }
626}
627
628field_table! {
629    /// A `CHART_EQUITY` minute candle. `seq` numbers the candles of the
630    /// day; Schwab's published field order for this service is wrong, this
631    /// table matches the wire.
632    ChartEquityFields => ChartEquity {
633        key = KEY;
634        envelope = { seq: i64 };
635        /// Candle index since the pre-market open.
636        1 SEQUENCE sequence "sequence": i64,
637        2 OPEN_PRICE open "open": f64,
638        3 HIGH_PRICE high "high": f64,
639        4 LOW_PRICE low "low": f64,
640        5 CLOSE_PRICE close "close": f64,
641        6 VOLUME volume "volume": f64,
642        /// Candle start, epoch milliseconds.
643        7 CHART_TIME chart_time "chartTime": i64,
644        /// Days since the epoch.
645        8 CHART_DAY chart_day "chartDay": i64,
646    }
647}
648
649field_table! {
650    /// A `CHART_FUTURES` minute candle.
651    ChartFuturesFields => ChartFutures {
652        key = KEY;
653        envelope = { seq: i64 };
654        /// Candle start, epoch milliseconds.
655        1 CHART_TIME chart_time "chartTime": i64,
656        2 OPEN_PRICE open "open": f64,
657        3 HIGH_PRICE high "high": f64,
658        4 LOW_PRICE low "low": f64,
659        5 CLOSE_PRICE close "close": f64,
660        6 VOLUME volume "volume": f64,
661    }
662}
663
664/// One ranked symbol in a [`Screener`] result. Named on the wire already.
665#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
666#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
667#[serde(rename_all = "camelCase")]
668#[non_exhaustive]
669pub struct ScreenerItem {
670    pub symbol: String,
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub description: Option<String>,
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub last_price: Option<f64>,
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub net_change: Option<f64>,
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub net_percent_change: Option<f64>,
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub market_share: Option<f64>,
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub total_volume: Option<i64>,
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub volume: Option<i64>,
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub trades: Option<i64>,
687    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
688    pub extra: HashMap<String, Value>,
689}
690
691field_table! {
692    /// A `SCREENER_EQUITY` or `SCREENER_OPTION` ranking. Every frame
693    /// carries the whole list.
694    ScreenerFields => Screener {
695        key = SYMBOL;
696        envelope = {};
697        /// Epoch milliseconds.
698        1 TIMESTAMP timestamp "timestamp": i64,
699        2 SORT_FIELD sort_field "sortField": String,
700        /// Minutes the ranking covers; `0` is the whole day.
701        3 FREQUENCY frequency "frequency": i64,
702        4 ITEMS items "items": Vec<ScreenerItem>,
703    }
704}
705
706field_table! {
707    /// An `ACCT_ACTIVITY` order event. `message_data` is JSON serialized
708    /// into a string for order events and plain text for errors; see
709    /// [`AccountActivity::message`] for the parsed form.
710    AcctActivityFields => AccountActivity {
711        key = KEY;
712        envelope = { seq: i64 };
713        1 ACCOUNT account_number "accountNumber": String,
714        /// `OrderCreated`, `OrderAccepted`, `OrderFillCompleted`, …
715        2 MESSAGE_TYPE message_type "messageType": String,
716        3 MESSAGE_DATA message_data "messageData": String,
717    }
718}
719
720impl AccountActivity {
721    /// `message_data` parsed as the JSON object an order event carries, or
722    /// `None` when it is absent or plain text (subscription errors).
723    ///
724    /// ```
725    /// use rustyschwab_types::streaming::AccountActivity;
726    ///
727    /// let event = AccountActivity {
728    ///     message_type: Some("OrderCreated".into()),
729    ///     message_data: Some(r#"{"SchwabOrderID":"1003811730601"}"#.into()),
730    ///     ..Default::default()
731    /// };
732    /// assert_eq!(event.message().unwrap()["SchwabOrderID"], "1003811730601");
733    ///
734    /// let rejected = AccountActivity {
735    ///     message_data: Some("Subscription rejected: bad key".into()),
736    ///     ..Default::default()
737    /// };
738    /// assert!(rejected.message().is_none());
739    /// ```
740    pub fn message(&self) -> Option<Map<String, Value>> {
741        match serde_json::from_str(self.message_data.as_deref()?) {
742            Ok(Value::Object(map)) => Some(map),
743            _ => None,
744        }
745    }
746}
747
748/// A [`DataContent`] decoded for its service.
749#[derive(Debug, Clone, PartialEq)]
750#[non_exhaustive]
751pub enum StreamPayload {
752    LevelOneEquity(LevelOneEquity),
753    LevelOneOption(LevelOneOption),
754    LevelOneFutures(LevelOneFutures),
755    LevelOneFuturesOption(LevelOneFuturesOption),
756    LevelOneForex(LevelOneForex),
757    Book(Book),
758    ChartEquity(ChartEquity),
759    ChartFutures(ChartFutures),
760    Screener(Screener),
761    AccountActivity(AccountActivity),
762}
763
764impl StreamService {
765    /// The field table for this service; empty for `ADMIN`, which carries
766    /// no data.
767    pub fn fields(self) -> &'static [Field] {
768        match self {
769            Self::Admin => &[],
770            Self::LeveloneEquities => LevelOneEquityFields::FIELDS,
771            Self::LeveloneOptions => LevelOneOptionFields::FIELDS,
772            Self::LeveloneFutures => LevelOneFuturesFields::FIELDS,
773            Self::LeveloneFuturesOptions => LevelOneFuturesOptionFields::FIELDS,
774            Self::LeveloneForex => LevelOneForexFields::FIELDS,
775            Self::NyseBook | Self::NasdaqBook | Self::OptionsBook => BookFields::FIELDS,
776            Self::ChartEquity => ChartEquityFields::FIELDS,
777            Self::ChartFutures => ChartFuturesFields::FIELDS,
778            Self::ScreenerEquity | Self::ScreenerOption => ScreenerFields::FIELDS,
779            Self::AcctActivity => AcctActivityFields::FIELDS,
780        }
781    }
782
783    /// Decode one content item of this service.
784    pub fn decode(self, content: &DataContent) -> Result<StreamPayload, serde_json::Error> {
785        use serde::de::Error as _;
786        Ok(match self {
787            Self::Admin => {
788                return Err(serde_json::Error::custom("ADMIN frames carry no data"));
789            }
790            Self::LeveloneEquities => {
791                StreamPayload::LevelOneEquity(LevelOneEquity::from_content(content)?)
792            }
793            Self::LeveloneOptions => {
794                StreamPayload::LevelOneOption(LevelOneOption::from_content(content)?)
795            }
796            Self::LeveloneFutures => {
797                StreamPayload::LevelOneFutures(LevelOneFutures::from_content(content)?)
798            }
799            Self::LeveloneFuturesOptions => {
800                StreamPayload::LevelOneFuturesOption(LevelOneFuturesOption::from_content(content)?)
801            }
802            Self::LeveloneForex => {
803                StreamPayload::LevelOneForex(LevelOneForex::from_content(content)?)
804            }
805            Self::NyseBook | Self::NasdaqBook | Self::OptionsBook => {
806                StreamPayload::Book(Book::from_content(content)?)
807            }
808            Self::ChartEquity => StreamPayload::ChartEquity(ChartEquity::from_content(content)?),
809            Self::ChartFutures => StreamPayload::ChartFutures(ChartFutures::from_content(content)?),
810            Self::ScreenerEquity | Self::ScreenerOption => {
811                StreamPayload::Screener(Screener::from_content(content)?)
812            }
813            Self::AcctActivity => {
814                StreamPayload::AccountActivity(AccountActivity::from_content(content)?)
815            }
816        })
817    }
818}
819
820impl DataItem {
821    /// The service this item belongs to, when it is one this crate knows.
822    pub fn service(&self) -> Option<StreamService> {
823        StreamService::from_api_str(&self.service)
824    }
825
826    /// Decode every content item for the item's service.
827    pub fn decode(&self) -> Result<Vec<StreamPayload>, serde_json::Error> {
828        use serde::de::Error as _;
829        let service = self.service().ok_or_else(|| {
830            serde_json::Error::custom(format!("unknown streamer service `{}`", self.service))
831        })?;
832        self.content
833            .iter()
834            .map(|content| service.decode(content))
835            .collect()
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use serde_json::json;
843
844    fn content(value: Value) -> DataContent {
845        serde_json::from_value(value).unwrap()
846    }
847
848    #[test]
849    fn tables_are_dense_and_unique() {
850        for service in [
851            StreamService::LeveloneEquities,
852            StreamService::LeveloneOptions,
853            StreamService::LeveloneFutures,
854            StreamService::LeveloneFuturesOptions,
855            StreamService::LeveloneForex,
856            StreamService::NyseBook,
857            StreamService::ChartEquity,
858            StreamService::ChartFutures,
859            StreamService::ScreenerEquity,
860            StreamService::AcctActivity,
861        ] {
862            let table = service.fields();
863            for (index, field) in table.iter().enumerate() {
864                assert_eq!(field.number as usize, index, "{service:?} {}", field.name);
865            }
866            let mut names: Vec<_> = table.iter().map(|field| field.name).collect();
867            names.sort_unstable();
868            names.dedup();
869            assert_eq!(names.len(), table.len(), "{service:?} names repeat");
870        }
871        assert_eq!(LevelOneEquityFields::FIELDS.len(), 52);
872        assert_eq!(LevelOneOptionFields::FIELDS.len(), 56);
873        assert_eq!(LevelOneFuturesFields::FIELDS.len(), 41);
874        assert_eq!(LevelOneFuturesOptionFields::FIELDS.len(), 32);
875        assert_eq!(LevelOneForexFields::FIELDS.len(), 30);
876        assert!(StreamService::Admin.fields().is_empty());
877    }
878
879    #[test]
880    fn payload_names_match_the_table() {
881        // Every table name must be a field the payload deserializes, so a
882        // fully-populated wire object round-trips with nothing in `extra`.
883        let numbered: Map<String, Value> = LevelOneEquityFields::FIELDS
884            .iter()
885            .skip(1)
886            .map(|field| {
887                let value = match field.name {
888                    "askId" | "bidId" | "lastId" | "exchangeId" | "description"
889                    | "exchangeName" | "divDate" | "securityStatus" | "askMICId" | "bidMICId"
890                    | "lastMICId" => json!("x"),
891                    "marginable" | "regularMarketQuote" | "regularMarketTrade" => json!(true),
892                    _ => json!(1),
893                };
894                (field.number.to_string(), value)
895            })
896            .collect();
897        let mut wire = numbered.clone();
898        wire.insert("key".into(), json!("AAPL"));
899        let quote = LevelOneEquity::from_content(&content(Value::Object(wire))).unwrap();
900        assert!(quote.extra.is_empty(), "unmapped: {:?}", quote.extra);
901        assert_eq!(quote.post_market_percent_change, Some(1.0));
902
903        let named = serde_json::to_value(&quote).unwrap();
904        for field in LevelOneEquityFields::FIELDS {
905            assert!(
906                named.get(field.name).is_some(),
907                "{} not serialized",
908                field.name
909            );
910        }
911    }
912
913    #[test]
914    fn decodes_deltas_and_keeps_unknown_fields() {
915        let content = content(json!({
916            "key": "AMD", "delayed": false, "assetMainType": "EQUITY",
917            "assetSubType": "COE", "cusip": "007903107",
918            "1": 217.86, "2": 217.95, "6": "P", "8": 33292396, "99": "future"
919        }));
920        let quote = LevelOneEquity::from_content(&content).unwrap();
921        assert_eq!(quote.key, "AMD");
922        assert_eq!(quote.delayed, Some(false));
923        assert_eq!(quote.asset_main_type, Some(AssetMainType::Equity));
924        assert_eq!(quote.asset_sub_type, Some(AssetSubType::Coe));
925        assert_eq!(quote.cusip.as_deref(), Some("007903107"));
926        assert_eq!(quote.ask_price, Some(217.95));
927        assert_eq!(quote.ask_id.as_deref(), Some("P"));
928        assert_eq!(quote.last_price, None);
929        assert_eq!(quote.extra["99"], "future");
930
931        let named = content.to_named_value(LevelOneEquityFields::FIELDS);
932        assert_eq!(named["bidPrice"], 217.86);
933        assert_eq!(named["cusip"], "007903107");
934        assert_eq!(named["99"], "future");
935        assert!(named.get("1").is_none());
936    }
937
938    #[test]
939    fn option_field_one_is_the_description() {
940        let quote = LevelOneOption::from_content(&content(json!({
941            "key": "SPXW  251208C06880000", "delayed": false, "assetMainType": "OPTION",
942            "1": "SPXW 12/08/2025 6880.00 C", "2": 10.2, "3": 10.6
943        })))
944        .unwrap();
945        assert_eq!(
946            quote.description.as_deref(),
947            Some("SPXW 12/08/2025 6880.00 C")
948        );
949        assert_eq!(quote.bid_price, Some(10.2));
950    }
951
952    #[test]
953    fn envelope_classes_outside_the_enum_decode_as_unknown() {
954        let quote = LevelOneEquity::from_content(&content(json!({
955            "key": "X", "assetMainType": "CRYPTO", "assetSubType": "NEW", "1": 1.0
956        })))
957        .unwrap();
958        assert_eq!(quote.asset_main_type, Some(AssetMainType::Unknown));
959        assert_eq!(quote.asset_sub_type, Some(AssetSubType::Unknown));
960        assert_eq!(quote.bid_price, Some(1.0));
961    }
962
963    #[test]
964    fn book_levels_decode_recursively() {
965        let book = Book::from_content(&content(json!({
966            "key": "F",
967            "1": 1765306322349i64,
968            "2": [{"0": 13.12, "1": 31600, "2": 2, "3": [
969                {"0": "NYSE", "1": 8800, "2": 49786299},
970                {"0": "ARCX", "1": 4200, "2": 49904606}
971            ]}],
972            "3": [{"0": 13.13, "1": 900, "2": 0, "3": []}]
973        })))
974        .unwrap();
975        let bids = book.bid_side_levels.as_ref().unwrap();
976        assert_eq!(bids[0].price, 13.12);
977        assert_eq!(bids[0].market_makers[1].market_maker_id, "ARCX");
978        assert_eq!(book.ask_side_levels.unwrap()[0].aggregate_size, 900);
979    }
980
981    #[test]
982    fn charts_screeners_and_activity_decode() {
983        let candle = ChartEquity::from_content(&content(json!({
984            "seq": 202, "key": "AMD", "1": 425, "2": 221.133, "5": 221.02,
985            "6": 11730, "7": 1765307100000i64, "8": 20431
986        })))
987        .unwrap();
988        assert_eq!(candle.seq, Some(202));
989        assert_eq!(candle.sequence, Some(425));
990        assert_eq!(candle.volume, Some(11730.0));
991
992        let screener = Screener::from_content(&content(json!({
993            "key": "NASDAQ_VOLUME_30", "1": 1765307297706i64, "2": "VOLUME", "3": 30,
994            "4": [{"symbol": "WBD", "lastPrice": 28.095, "trades": 21302}]
995        })))
996        .unwrap();
997        assert_eq!(screener.items.unwrap()[0].trades, Some(21302));
998
999        let activity = AccountActivity::from_content(&content(json!({
1000            "seq": 1, "key": "Account Activity",
1001            "1": "123456789", "2": "OrderCreated", "3": "{\"SchwabOrderID\":\"1\"}"
1002        })))
1003        .unwrap();
1004        assert_eq!(activity.account_number.as_deref(), Some("123456789"));
1005        assert_eq!(activity.message_type.as_deref(), Some("OrderCreated"));
1006    }
1007
1008    #[test]
1009    fn items_decode_by_service() {
1010        let item: DataItem = serde_json::from_value(json!({
1011            "service": "LEVELONE_FOREX", "timestamp": 1, "command": "SUBS",
1012            "content": [{"key": "EUR/USD", "1": 1.1643, "2": 1.16443}]
1013        }))
1014        .unwrap();
1015        assert_eq!(item.service(), Some(StreamService::LeveloneForex));
1016        match item.decode().unwrap().as_slice() {
1017            [StreamPayload::LevelOneForex(quote)] => assert_eq!(quote.ask_price, Some(1.16443)),
1018            other => panic!("{other:?}"),
1019        }
1020
1021        let unknown = DataItem {
1022            service: "LEVELTWO".into(),
1023            ..item.clone()
1024        };
1025        assert!(
1026            unknown
1027                .decode()
1028                .unwrap_err()
1029                .to_string()
1030                .contains("LEVELTWO")
1031        );
1032        assert!(StreamService::Admin.decode(&item.content[0]).is_err());
1033
1034        let wrong_type = content(json!({"key": "EUR/USD", "1": "not a price"}));
1035        assert!(LevelOneForex::from_content(&wrong_type).is_err());
1036    }
1037
1038    #[test]
1039    fn subscription_field_lists_come_from_the_tables() {
1040        assert_eq!(
1041            LevelOneEquityFields::default_fields(),
1042            "0,1,2,3,4,5,8,9,10,11,12,17,18,42,34,35"
1043        );
1044        assert_eq!(LevelOneEquityFields::all_fields(), csv(0..=51));
1045        assert_eq!(
1046            StreamService::LeveloneEquities.default_fields(),
1047            LevelOneEquityFields::default_fields()
1048        );
1049        assert_eq!(
1050            StreamService::LeveloneOptions.default_fields(),
1051            LevelOneOptionFields::all_fields()
1052        );
1053        assert_eq!(StreamService::NasdaqBook.default_fields(), "0,1,2,3");
1054        assert_eq!(StreamService::AcctActivity.default_fields(), "0,1,2,3");
1055        assert_eq!(StreamService::Admin.default_fields(), "");
1056        assert_eq!(LevelOneEquityFields::name(0), Some("key"));
1057        assert_eq!(LevelOneEquityFields::name(52), None);
1058    }
1059}