schwab-sdk 0.4.0

Async Rust client for the Charles Schwab Trader API and real-time market-data streaming.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! `LEVELONE_EQUITIES` streamer service.
//!
//! Delivery type: Change. Fields not present on a tick stay `None`.

use rust_decimal::Decimal;
use rust_decimal::serde::float_option as decimal_opt;
use serde::Deserialize;
use strum::{Display, EnumString, FromRepr};

use crate::error::{Error, Result};
use crate::streamer::{Service, subscription::SubscriptionField};

impl SubscriptionField for Field {
    const SERVICE: Service = Service::LevelOneEquities;
}

#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    Hash,
    serde_repr::Serialize_repr,
    Display,
    EnumString,
    FromRepr,
)]
/// Numbered subscription field for LEVELONE_EQUITIES.
///
/// Pass any combination to [`SubscribeRequest::fields`](crate::streamer::SubscribeRequest::fields);
/// each variant corresponds 1:1 with the matching field on [`Content`].
#[repr(u8)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum Field {
    /// Wire symbol (field 0).
    Symbol,
    /// Best bid, USD (field 1).
    BidPrice,
    /// Best ask, USD (field 2).
    AskPrice,
    /// Last trade price, USD (field 3).
    LastPrice,
    /// Best bid size (field 4).
    BidSize,
    /// Best ask size (field 5).
    AskSize,
    /// MIC venue id for the best ask (field 6).
    AskId,
    /// MIC venue id for the best bid (field 7).
    BidId,
    /// Cumulative session volume (field 8).
    TotalVolume,
    /// Last trade size (field 9).
    LastSize,
    /// Day high, USD (field 10).
    HighPrice,
    /// Day low, USD (field 11).
    LowPrice,
    /// Prior session close, USD (field 12).
    ClosePrice,
    /// Schwab exchange code (field 13).
    ExchangeId,
    /// `true` if the security is marginable (field 14).
    Marginable,
    /// Human-readable description (field 15).
    Description,
    /// MIC venue id for the last trade (field 16).
    LastId,
    /// Day open, USD (field 17).
    OpenPrice,
    /// Net change since prior close, USD (field 18).
    NetChange,
    /// 52-week high, USD (field 19).
    High52WeekPrice,
    /// 52-week low, USD (field 20).
    Low52WeekPrice,
    /// P/E ratio (field 21).
    PeRatio,
    /// Annual dividend amount, USD per share (field 22).
    AnnualDividendAmount,
    /// Trailing dividend yield as a fraction (field 23).
    DividendYield,
    /// Net asset value for funds (field 24).
    Nav,
    /// Exchange display name (field 25).
    ExchangeName,
    /// Dividend date string (field 26).
    DividendDate,
    /// `true` if a regular-session quote is available (field 27).
    RegularMarketQuote,
    /// `true` if a regular-session trade has occurred (field 28).
    RegularMarketTrade,
    /// Last regular-session trade price, USD (field 29).
    RegularMarketLastPrice,
    /// Last regular-session trade size (field 30).
    RegularMarketLastSize,
    /// Regular-session net change, USD (field 31).
    RegularMarketNetChange,
    /// Security status string (field 32).
    SecurityStatus,
    /// Mark price, USD (field 33).
    MarkPrice,
    /// Last quote time, epoch milliseconds (field 34).
    QuoteTime,
    /// Last trade time, epoch milliseconds (field 35).
    TradeTime,
    /// Last regular-session trade time, epoch milliseconds (field 36).
    RegularMarketTradeTime,
    /// Last bid time, epoch milliseconds (field 37).
    BidTime,
    /// Last ask time, epoch milliseconds (field 38).
    AskTime,
    /// MIC venue id for the best ask (field 39).
    AskMicId,
    /// MIC venue id for the best bid (field 40).
    BidMicId,
    /// MIC venue id for the last trade (field 41).
    LastMicId,
    /// Net change since prior close as a fraction (field 42).
    NetPercentageChange,
    /// Regular-session change as a fraction (field 43).
    RegularMarketPercentageChange,
    /// Mark change since prior close, USD (field 44).
    MarkPriceNetChange,
    /// Mark change since prior close as a fraction (field 45).
    MarkPricePercentageChange,
    /// Hard-to-borrow available quantity (field 46).
    HardToBorrowQuantity,
    /// Hard-to-borrow annualized rate (field 47).
    HardToBorrowRate,
    /// Hard-to-borrow flag (field 48).
    HardToBorrow,
    /// Shortable flag (field 49).
    Shortable,
    /// Post-market net change, USD (field 50).
    PostMarketNetChange,
    /// Post-market change as a fraction (field 51).
    PostMarketPercentageChange,
}

impl From<Field> for u8 {
    fn from(field: Field) -> Self {
        field as u8
    }
}

impl TryFrom<u8> for Field {
    type Error = String;
    fn try_from(value: u8) -> std::result::Result<Self, Self::Error> {
        Field::from_repr(value).ok_or_else(|| format!("Invalid field: {}", value))
    }
}

/// Typed payload for a single LEVELONE_EQUITIES update.
///
/// LEVELONE_EQUITIES uses Schwab's "Change" delivery type: only the fields
/// that changed since the previous tick are present. Every numeric-indexed
/// field is therefore `Option<T>`. The `key`, `delayed`, `assetMainType`,
/// `assetSubType`, and `cusip` fields appear on every message and are not
/// numerically indexed; the remaining fields correspond 1:1 with the
/// `Field` enum above.
///
/// **Timestamps** are milliseconds since the Unix epoch (`u64`).
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq, Hash)]
#[serde(default)]
#[non_exhaustive]
pub struct Content {
    /// Subscription key (the symbol the update is for).
    pub key: String,
    /// `true` if the quote is delayed.
    pub delayed: bool,
    /// Asset class string (`"EQUITY"` for this service).
    #[serde(rename = "assetMainType")]
    pub asset_main_type: Option<String>,
    /// Asset sub-type string (e.g. `"COE"`, `"ETF"`).
    #[serde(rename = "assetSubType")]
    pub asset_sub_type: Option<String>,
    /// CUSIP.
    pub cusip: Option<String>,

    /// Field 0: wire symbol.
    pub symbol: Option<String>,
    /// Field 1: best bid, USD.
    #[serde(with = "decimal_opt")]
    pub bid_price: Option<Decimal>,
    /// Field 2: best ask, USD.
    #[serde(with = "decimal_opt")]
    pub ask_price: Option<Decimal>,
    /// Field 3: last trade price, USD.
    #[serde(with = "decimal_opt")]
    pub last_price: Option<Decimal>,
    /// Field 4: best bid size.
    pub bid_size: Option<u64>,
    /// Field 5: best ask size.
    pub ask_size: Option<u64>,
    /// Field 6: MIC venue id for the best ask.
    pub ask_id: Option<String>,
    /// Field 7: MIC venue id for the best bid.
    pub bid_id: Option<String>,
    /// Field 8: cumulative session volume.
    pub total_volume: Option<u64>,
    /// Field 9: last trade size.
    pub last_size: Option<u64>,
    /// Field 10: day high, USD.
    #[serde(with = "decimal_opt")]
    pub high_price: Option<Decimal>,
    /// Field 11: day low, USD.
    #[serde(with = "decimal_opt")]
    pub low_price: Option<Decimal>,
    /// Field 12: prior session close, USD.
    #[serde(with = "decimal_opt")]
    pub close_price: Option<Decimal>,
    /// Field 13: Schwab exchange code.
    pub exchange_id: Option<String>,
    /// Field 14: `true` if the security is marginable.
    pub marginable: Option<bool>,
    /// Field 15: human-readable description.
    pub description: Option<String>,
    /// Field 16: MIC venue id for the last trade.
    pub last_id: Option<String>,
    /// Field 17: day open, USD.
    #[serde(with = "decimal_opt")]
    pub open_price: Option<Decimal>,
    /// Field 18: net change since prior close, USD.
    #[serde(with = "decimal_opt")]
    pub net_change: Option<Decimal>,
    /// Field 19: 52-week high, USD.
    #[serde(with = "decimal_opt")]
    pub high52_week_price: Option<Decimal>,
    /// Field 20: 52-week low, USD.
    #[serde(with = "decimal_opt")]
    pub low52_week_price: Option<Decimal>,
    /// Field 21: P/E ratio.
    #[serde(with = "decimal_opt")]
    pub pe_ratio: Option<Decimal>,
    /// Field 22: annual dividend amount, USD per share.
    #[serde(with = "decimal_opt")]
    pub annual_dividend_amount: Option<Decimal>,
    /// Field 23: trailing dividend yield as a fraction.
    #[serde(with = "decimal_opt")]
    pub dividend_yield: Option<Decimal>,
    /// Field 24: net asset value for funds, USD.
    #[serde(with = "decimal_opt")]
    pub nav: Option<Decimal>,
    /// Field 25: exchange display name.
    pub exchange_name: Option<String>,
    /// Field 26: dividend date string.
    pub dividend_date: Option<String>,
    /// Field 27: `true` if a regular-session quote is available.
    pub regular_market_quote: Option<bool>,
    /// Field 28: `true` if a regular-session trade has occurred.
    pub regular_market_trade: Option<bool>,
    /// Field 29: last regular-session trade price, USD.
    #[serde(with = "decimal_opt")]
    pub regular_market_last_price: Option<Decimal>,
    /// Field 30: last regular-session trade size.
    pub regular_market_last_size: Option<u64>,
    /// Field 31: regular-session net change, USD.
    #[serde(with = "decimal_opt")]
    pub regular_market_net_change: Option<Decimal>,
    /// Field 32: security status string.
    pub security_status: Option<String>,
    /// Field 33: mark price, USD.
    #[serde(with = "decimal_opt")]
    pub mark_price: Option<Decimal>,
    /// Field 34: last quote time, epoch milliseconds.
    pub quote_time: Option<u64>,
    /// Field 35: last trade time, epoch milliseconds.
    pub trade_time: Option<u64>,
    /// Field 36: last regular-session trade time, epoch milliseconds.
    pub regular_market_trade_time: Option<u64>,
    /// Field 37: last bid time, epoch milliseconds.
    pub bid_time: Option<u64>,
    /// Field 38: last ask time, epoch milliseconds.
    pub ask_time: Option<u64>,
    /// Field 39: MIC venue id for the best ask.
    pub ask_mic_id: Option<String>,
    /// Field 40: MIC venue id for the best bid.
    pub bid_mic_id: Option<String>,
    /// Field 41: MIC venue id for the last trade.
    pub last_mic_id: Option<String>,
    /// Field 42: net change since prior close as a fraction.
    #[serde(with = "decimal_opt")]
    pub net_percentage_change: Option<Decimal>,
    /// Field 43: regular-session change as a fraction.
    #[serde(with = "decimal_opt")]
    pub regular_market_percentage_change: Option<Decimal>,
    /// Field 44: mark change since prior close, USD.
    #[serde(with = "decimal_opt")]
    pub mark_price_net_change: Option<Decimal>,
    /// Field 45: mark change since prior close as a fraction.
    #[serde(with = "decimal_opt")]
    pub mark_price_percentage_change: Option<Decimal>,
    /// Field 46: hard-to-borrow available quantity.
    pub hard_to_borrow_quantity: Option<i64>,
    /// Field 47: hard-to-borrow annualized rate.
    #[serde(with = "decimal_opt")]
    pub hard_to_borrow_rate: Option<Decimal>,
    /// Field 48: hard-to-borrow flag (Schwab uses a small-int sentinel).
    pub hard_to_borrow: Option<i8>,
    /// Field 49: shortable flag (Schwab uses a small-int sentinel).
    pub shortable: Option<i8>,
    /// Field 50: post-market net change, USD.
    #[serde(with = "decimal_opt")]
    pub post_market_net_change: Option<Decimal>,
    /// Field 51: post-market change as a fraction.
    #[serde(with = "decimal_opt")]
    pub post_market_percentage_change: Option<Decimal>,
}

impl Content {
    /// Decode a remapped JSON object (numeric keys already resolved to
    /// snake_case names by the streamer frame parser) into a typed batch.
    pub(crate) fn decode_batch(remapped: serde_json::Value) -> Result<Vec<Self>> {
        serde_json::from_value(remapped).map_err(|e| Error::Codec {
            context: "LEVELONE_EQUITIES content".to_string(),
            reason: e.to_string(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::streamer::StreamerRequest;
    use crate::streamer::response::{DataContent, parse};
    use crate::streamer::{StreamerResponse, SubscriptionCommand};
    use rust_decimal_macros::dec;

    #[test]
    fn parses_level_one_equities_data_into_typed_content() {
        let frame = r#"{
            "data": [{
                "service": "LEVELONE_EQUITIES",
                "timestamp": 1714949592301,
                "command": "SUBS",
                "content": [
                    {
                        "key": "SCHW",
                        "delayed": false,
                        "assetMainType": "EQUITY",
                        "assetSubType": "COE",
                        "cusip": "808513105",
                        "1": 76.08, "2": 76.49, "3": 76.44,
                        "4": 3, "5": 1, "8": 5414735, "10": 76.47
                    },
                    {
                        "key": "AAPL",
                        "delayed": false,
                        "assetMainType": "EQUITY",
                        "assetSubType": "COE",
                        "cusip": "037833100",
                        "1": 183.75, "2": 183.8, "3": 183.8,
                        "4": 1, "5": 2, "8": 163224109, "10": 187
                    }
                ]
            }]
        }"#;
        let StreamerResponse::Data(data) = parse(frame).unwrap() else {
            panic!("expected Data");
        };
        assert_eq!(data.len(), 1);
        let payload = &data[0];
        assert_eq!(payload.service, Service::LevelOneEquities);
        assert_eq!(payload.timestamp, 1714949592301);
        assert_eq!(payload.command, SubscriptionCommand::Subscribe);

        let DataContent::LevelOneEquities(items) = &payload.content else {
            panic!("expected LevelOneEquities, got {:?}", payload.content);
        };
        assert_eq!(items.len(), 2);

        let schw = &items[0];
        assert_eq!(schw.key, "SCHW");
        assert!(!schw.delayed);
        assert_eq!(schw.cusip.as_deref(), Some("808513105"));
        assert_eq!(schw.bid_price, Some(dec!(76.08)));
        assert_eq!(schw.ask_price, Some(dec!(76.49)));
        assert_eq!(schw.last_price, Some(dec!(76.44)));
        assert_eq!(schw.bid_size, Some(3));
        assert_eq!(schw.ask_size, Some(1));
        assert_eq!(schw.total_volume, Some(5414735));
        assert_eq!(schw.high_price, Some(dec!(76.47)));
        // Fields not present on the wire stay None.
        assert_eq!(schw.low_price, None);
        assert_eq!(schw.dividend_yield, None);

        let aapl = &items[1];
        assert_eq!(aapl.key, "AAPL");
        assert_eq!(aapl.bid_price, Some(dec!(183.75)));
        assert_eq!(aapl.last_price, Some(dec!(183.8)));
    }

    #[test]
    fn test_serialize_parameters() {
        use crate::streamer::subscription::subscribe_parameters;

        let value = subscribe_parameters(
            vec!["AAPL".to_string()],
            vec![Field::Symbol, Field::BidPrice, Field::AskPrice],
        );
        assert_eq!(value["keys"], "AAPL");
        assert_eq!(value["fields"], "0,1,2");
    }

    #[test]
    fn from_subscription_never_panics() {
        use crate::streamer::subscription::{Command, Subscription};

        let sub = Subscription {
            command: Command::Subscribe,
            keys: vec!["AAPL".to_string(), "MSFT,with,commas".to_string()],
            fields: vec![Field::Symbol, Field::LastPrice],
        };
        let _request: StreamerRequest = sub.into();

        let sub = Subscription::<Field> {
            command: Command::Unsubscribe,
            keys: vec![],
            fields: vec![],
        };
        let _request: StreamerRequest = sub.into();
    }
}