Skip to main content

rustyschwab_types/
common.rs

1//! Common types shared across multiple API modules.
2//!
3//! This module contains shared enumerations and structures used throughout
4//! the Schwab API, including:
5//!
6//! - Asset types: [`AssetType`](crate::common::AssetType) on the Trader API
7//!   (positions, order legs, transactions) and
8//!   [`AssetMainType`](crate::common::AssetMainType) on Market Data
9//! - [`AccountsInstrument`](crate::common::AccountsInstrument), the
10//!   instrument shape positions and order legs share
11//! - [`schwab_timestamp`](crate::common::schwab_timestamp), the one
12//!   timestamp spelling the Trader API's time-window query parameters accept
13//! - Response headers
14//! - Pagination info
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize, de::DeserializeOwned};
18use serde_with::skip_serializing_none;
19use std::collections::HashMap;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[serde(rename_all = "camelCase")]
24#[non_exhaustive]
25pub struct ResponseHeaders {
26    #[serde(rename = "Schwab-Client-CorrelId")]
27    pub schwab_client_correlid: Option<String>,
28    #[serde(rename = "Schwab-Resource-Version")]
29    pub schwab_resource_version: Option<String>,
30}
31
32/// Instrument class as the Trader API spells `assetType` on positions,
33/// order legs, and transaction items.
34///
35/// This is the spec's complete `assetType` enumeration; the per-instrument
36/// base schemas list only the first eight, and the remaining three appear
37/// in transaction instruments. See [`Wire`] for `Unknown`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
40#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
41#[non_exhaustive]
42pub enum AssetType {
43    #[default]
44    Equity,
45    Option,
46    Index,
47    MutualFund,
48    CashEquivalent,
49    FixedIncome,
50    Currency,
51    CollectiveInvestment,
52    Future,
53    Forex,
54    Product,
55    #[serde(other)]
56    #[cfg_attr(feature = "schemars", schemars(skip))]
57    Unknown,
58}
59
60/// An enum that appears on both requests and broker responses.
61///
62/// Responses decode a value this release does not know as `Unknown`, so one
63/// new spelling from Schwab cannot fail a whole order list or account. A
64/// request can never carry `Unknown`: the variant is absent from the JSON
65/// schemas, and [`OrderRequest::validate`](crate::trading::OrderRequest::validate)
66/// and the other request validators reject it.
67pub trait Wire {
68    /// Whether this is the `Unknown` catch-all rather than a value Schwab
69    /// documents.
70    fn is_unknown(&self) -> bool;
71}
72
73/// Implements [`Wire`] for enums whose catch-all variant is `Unknown`.
74macro_rules! wire_enum {
75    ($($ty:ty),+ $(,)?) => {$(
76        impl $crate::common::Wire for $ty {
77            fn is_unknown(&self) -> bool {
78                matches!(self, Self::Unknown)
79            }
80        }
81    )+};
82}
83pub(crate) use wire_enum;
84
85wire_enum!(AssetType);
86
87/// Reject `Unknown` in a request field, naming the field.
88pub(crate) fn known<T: Wire>(
89    field: &str,
90    value: Option<&T>,
91) -> Result<(), crate::validation::ValidationError> {
92    match value {
93        Some(v) if v.is_unknown() => Err(crate::validation::ValidationError::new(
94            crate::validation::ValidationErrorKind::Unsupported,
95            Some(field),
96            format!("{field} is not a value Schwab accepts"),
97        )),
98        _ => Ok(()),
99    }
100}
101
102/// Instrument class as Market Data spells `assetMainType` on quotes and
103/// stream envelopes and `assetType` on instruments. A different value set
104/// from the Trader API's [`AssetType`].
105///
106/// `Unknown` is both Schwab's own `UNKNOWN` and any value added after this
107/// release, so decoding never fails on it.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
110#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
111#[non_exhaustive]
112pub enum AssetMainType {
113    Bond,
114    Equity,
115    Etf,
116    Extended,
117    Forex,
118    Future,
119    FutureOption,
120    Fundamental,
121    Index,
122    Indicator,
123    MutualFund,
124    Option,
125    #[serde(other)]
126    Unknown,
127}
128
129/// The instrument on a position or an order leg (`AccountsInstrument` in
130/// the Trader API spec).
131///
132/// The fields Schwab documents for every asset type are typed, plus the
133/// option and fixed-income extensions; anything else lands in `extra`.
134#[skip_serializing_none]
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
136#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
137// A position or leg Schwab sends without a field must not fail the list.
138#[serde(rename_all = "camelCase", default)]
139#[non_exhaustive]
140pub struct AccountsInstrument {
141    pub asset_type: AssetType,
142    pub symbol: String,
143    pub cusip: Option<String>,
144    pub description: Option<String>,
145    pub instrument_id: Option<i64>,
146    pub net_change: Option<f64>,
147    /// Subtype whose value set depends on `asset_type` (`SWEEP_VEHICLE`
148    /// for cash equivalents, `VANILLA` for options, …).
149    #[serde(rename = "type")]
150    pub type_: Option<String>,
151    /// Options only. `None` when Schwab reports `UNKNOWN`.
152    #[serde(default, deserialize_with = "lenient")]
153    pub put_call: Option<PutCall>,
154    /// Options only.
155    pub underlying_symbol: Option<String>,
156    /// Options only.
157    pub option_multiplier: Option<i64>,
158    /// Fixed income only.
159    pub maturity_date: Option<String>,
160    /// Fixed income only.
161    pub factor: Option<f64>,
162    /// Fixed income only.
163    pub variable_rate: Option<f64>,
164    #[serde(flatten)]
165    pub extra: HashMap<String, serde_json::Value>,
166}
167
168/// Instrument subclass as Market Data spells `assetSubType` beside
169/// [`AssetMainType`]: the equity subtypes (`COE` common stock, `PRF`
170/// preferred, `ADR`/`GDR` depositary receipts, `CEF`, `ETF`, `ETN`, `UIT`,
171/// `WAR` warrant, `RGT` right) and the mutual-fund ones (`OEF`, `CEF`,
172/// `MMF`). Values outside the list decode as `Unknown`.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
174#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
175#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
176#[non_exhaustive]
177pub enum AssetSubType {
178    Adr,
179    Cef,
180    Coe,
181    Etf,
182    Etn,
183    Gdr,
184    Mmf,
185    Oef,
186    Prf,
187    Rgt,
188    Uit,
189    War,
190    #[serde(other)]
191    Unknown,
192}
193
194/// Option contract right, as Schwab spells it in `putCall` fields.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
196#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
197#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
198pub enum PutCall {
199    Put,
200    Call,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
205#[serde(rename_all = "camelCase")]
206#[non_exhaustive]
207pub struct PageInfo {
208    pub page_size: i32,
209    pub page_number: i32,
210    pub total_pages: i32,
211    pub total_items: i32,
212}
213
214/// Rewrite every float in `value` that holds an exact integer (`100.0`) as
215/// that integer, so it decodes into an integer field; other numbers are
216/// untouched (an integer still decodes into a float field). Only values
217/// within ±2^53, where a float is exact, are rewritten.
218pub fn normalize_integral_numbers(value: &mut serde_json::Value) {
219    use serde_json::Value;
220    const EXACT: f64 = 9_007_199_254_740_992.0;
221    match value {
222        Value::Number(number) if number.is_f64() => {
223            if let Some(float) = number.as_f64()
224                && float.fract() == 0.0
225                && float.abs() <= EXACT
226            {
227                *value = Value::from(float as i64);
228            }
229        }
230        Value::Array(items) => items.iter_mut().for_each(normalize_integral_numbers),
231        Value::Object(fields) => fields.values_mut().for_each(normalize_integral_numbers),
232        _ => {}
233    }
234}
235
236/// Broker identifiers may be JSON integers or strings. Normalize losslessly.
237pub fn optional_id<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Option<String>, D::Error> {
238    let value = Option::<serde_json::Value>::deserialize(de)?;
239    match value {
240        None => Ok(None),
241        Some(serde_json::Value::String(s)) => Ok(Some(s)),
242        Some(serde_json::Value::Number(n)) if n.is_u64() || n.is_i64() => Ok(Some(n.to_string())),
243        _ => Err(serde::de::Error::custom(
244            "Expected an integer or string identifier",
245        )),
246    }
247}
248
249/// Decode an optional enum on a broker response, reading values outside
250/// the enum (Schwab's `UNKNOWN`, or a value added after this release) as
251/// `None` instead of failing the whole payload. Pair with
252/// `#[serde(default)]` so a missing field is `None` too.
253pub fn lenient<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
254where
255    D: serde::Deserializer<'de>,
256    T: DeserializeOwned,
257{
258    let value = Option::<serde_json::Value>::deserialize(de)?;
259    Ok(value.and_then(|v| serde_json::from_value(v).ok()))
260}
261
262/// Deserialize a request decimal exactly.
263///
264/// Accepts a plain decimal string (`"150.25"`) or a JSON integer. A JSON
265/// fraction is accepted as its shortest round-trip spelling, but it may
266/// already have lost precision in the parser, so callers should send
267/// strings. Rejected rather than reinterpreted: digit separators (`"1_000"`,
268/// which `rust_decimal` would read as 1000), surrounding whitespace, exponent
269/// notation, and more digits than a `Decimal` holds (which it would round).
270pub mod exact_decimal {
271    use rust_decimal::Decimal;
272    use serde::de::{self, Deserializer, Visitor};
273    use std::fmt;
274
275    pub fn parse(s: &str) -> Result<Decimal, String> {
276        let plain = !s.is_empty()
277            && s.bytes()
278                .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+');
279        if !plain {
280            return Err(format!("{s:?} is not a plain decimal number"));
281        }
282        Decimal::from_str_exact(s).map_err(|e| format!("{s:?} is not an exact decimal: {e}"))
283    }
284
285    struct Exact;
286
287    impl Visitor<'_> for Exact {
288        type Value = Decimal;
289
290        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291            f.write_str("a decimal number as a string, such as \"150.25\"")
292        }
293
294        fn visit_str<E: de::Error>(self, s: &str) -> Result<Decimal, E> {
295            parse(s).map_err(E::custom)
296        }
297
298        fn visit_i64<E: de::Error>(self, n: i64) -> Result<Decimal, E> {
299            Ok(Decimal::from(n))
300        }
301
302        fn visit_u64<E: de::Error>(self, n: u64) -> Result<Decimal, E> {
303            Ok(Decimal::from(n))
304        }
305
306        fn visit_f64<E: de::Error>(self, n: f64) -> Result<Decimal, E> {
307            if !n.is_finite() {
308                return Err(E::custom("decimal must be finite"));
309            }
310            // `{}` on f64 is the shortest round-trip spelling, never exponent form.
311            parse(&n.to_string()).map_err(E::custom)
312        }
313    }
314
315    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Decimal, D::Error> {
316        de.deserialize_any(Exact)
317    }
318
319    /// Serialize as a JSON number, the form Schwab's order examples always
320    /// use for `quantity` (prices appear as strings as well, so they stay
321    /// strings). Whole values go out as integers; a value the shortest
322    /// float spelling would not carry exactly is refused.
323    pub fn serialize_number<S: serde::Serializer>(
324        value: &Decimal,
325        s: S,
326    ) -> Result<S::Ok, S::Error> {
327        use rust_decimal::prelude::ToPrimitive;
328        use serde::ser::Error;
329        if value.fract().is_zero()
330            && let Some(n) = value.to_i64()
331        {
332            return s.serialize_i64(n);
333        }
334        match value.to_f64() {
335            Some(n) if parse(&n.to_string()).is_ok_and(|back| back == *value) => s.serialize_f64(n),
336            _ => Err(S::Error::custom(format!(
337                "{value} has more precision than a JSON number carries"
338            ))),
339        }
340    }
341
342    /// Serialize as an exact decimal string (`"150.25"`), the form the SDK
343    /// sends prices in. Explicit, so a dependency enabling rust_decimal's
344    /// `serde-float` feature cannot turn prices into floats (and change
345    /// every intent digest over them).
346    pub fn serialize_string<S: serde::Serializer>(
347        value: &Decimal,
348        s: S,
349    ) -> Result<S::Ok, S::Error> {
350        s.collect_str(value)
351    }
352
353    /// The same rules for an optional field; pair with `#[serde(default)]`.
354    pub mod option {
355        use rust_decimal::Decimal;
356        use serde::{Deserialize, Deserializer, Serializer};
357
358        pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<Decimal>, D::Error> {
359            #[derive(Deserialize)]
360            struct Wrap(#[serde(deserialize_with = "super::deserialize")] Decimal);
361            Ok(Option::<Wrap>::deserialize(de)?.map(|Wrap(d)| d))
362        }
363
364        pub fn serialize_number<S: Serializer>(
365            value: &Option<Decimal>,
366            s: S,
367        ) -> Result<S::Ok, S::Error> {
368            match value {
369                Some(value) => super::serialize_number(value, s),
370                None => s.serialize_none(),
371            }
372        }
373
374        pub fn serialize_string<S: Serializer>(
375            value: &Option<Decimal>,
376            s: S,
377        ) -> Result<S::Ok, S::Error> {
378            match value {
379                Some(value) => super::serialize_string(value, s),
380                None => s.serialize_none(),
381            }
382        }
383    }
384}
385
386/// JSON schema for a request decimal: a string of digits with an optional
387/// fraction, the only spelling [`exact_decimal`] guarantees is exact.
388#[cfg(feature = "schemars")]
389#[derive(Debug)]
390pub struct DecimalString;
391
392#[cfg(feature = "schemars")]
393impl schemars::JsonSchema for DecimalString {
394    fn schema_name() -> std::borrow::Cow<'static, str> {
395        "DecimalString".into()
396    }
397
398    fn inline_schema() -> bool {
399        true
400    }
401
402    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
403        schemars::json_schema!({
404            "type": "string",
405            "pattern": "^[0-9]+(\\.[0-9]+)?$",
406            "description": "Exact decimal as a string, e.g. \"150.25\"; never a JSON number."
407        })
408    }
409}
410
411/// JSON schema for an order quantity: Schwab's wire form is a JSON number
412/// ([`exact_decimal::serialize_number`]), and a decimal string reads the same.
413#[cfg(feature = "schemars")]
414#[derive(Debug)]
415pub struct QuantityNumber;
416
417#[cfg(feature = "schemars")]
418impl schemars::JsonSchema for QuantityNumber {
419    fn schema_name() -> std::borrow::Cow<'static, str> {
420        "QuantityNumber".into()
421    }
422
423    fn inline_schema() -> bool {
424        true
425    }
426
427    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
428        schemars::json_schema!({
429            "type": ["number", "string"],
430            "pattern": "^[0-9]+(\\.[0-9]+)?$",
431            "description": "Shares, contracts or fund units: a JSON number such as 10, \
432                            or a decimal string such as \"10\"."
433        })
434    }
435}
436
437/// Format a timestamp for the Trader API's time-window query parameters
438/// (`fromEnteredTime`, `toEnteredTime`, `startDate`, `endDate`).
439///
440/// Schwab documents exactly one shape, `yyyy-MM-dd'T'HH:mm:ss.SSSZ`: UTC,
441/// a literal `Z`, and exactly three fractional digits. RFC 3339 spellings
442/// that omit the fraction or carry more digits are not the documented form,
443/// so the crate never emits them.
444///
445/// ```
446/// use chrono::{TimeZone, Utc};
447/// use rustyschwab_types::common::schwab_timestamp;
448///
449/// let at = Utc.with_ymd_and_hms(2025, 1, 15, 9, 30, 0).unwrap();
450/// assert_eq!(schwab_timestamp(at), "2025-01-15T09:30:00.000Z");
451/// ```
452pub fn schwab_timestamp(at: DateTime<Utc>) -> String {
453    at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
454}
455
456/// Parse a time as Schwab reports it on orders and transactions
457/// (`2025-01-15T14:31:22+0000`, with or without a fraction), or as RFC 3339.
458pub fn parse_schwab_time(text: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
459    DateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%z")
460        .or_else(|_| DateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f%z"))
461        .or_else(|_| DateTime::parse_from_rfc3339(text))
462        .map(|at| at.with_timezone(&Utc))
463}
464
465/// `serialize_with` adapter for [`schwab_timestamp`].
466pub(crate) fn serialize_timestamp<S: serde::Serializer>(
467    at: &DateTime<Utc>,
468    serializer: S,
469) -> Result<S::Ok, S::Error> {
470    serializer.serialize_str(&schwab_timestamp(*at))
471}
472
473/// [`serialize_timestamp`] for an optional field.
474pub(crate) fn serialize_optional_timestamp<S: serde::Serializer>(
475    at: &Option<DateTime<Utc>>,
476    serializer: S,
477) -> Result<S::Ok, S::Error> {
478    match at {
479        Some(at) => serialize_timestamp(at, serializer),
480        None => serializer.serialize_none(),
481    }
482}