Skip to main content

dbn/python/
repr.rs

1//! Python-specific `__repr__` implementation support.
2//!
3//! This module provides traits and helpers for generating Python-style string
4//! representations of DBN records. Unlike Rust's `Debug` trait, Python repr:
5//! - Flattens nested structs (e.g. `RecordHeader`) to match how they appear in
6//!   Python
7//! - Uses Python enum syntax: `EnumName.VARIANT` instead of `EnumName::Variant`
8
9use std::fmt::{self, Write};
10use std::os::raw::c_char;
11
12use crate::pretty;
13use crate::record::c_chars_to_str;
14use crate::{BidAskPair, ConsolidatedBidAskPair, FlagSet, RecordHeader};
15
16/// Trait for Python-specific `__repr__` output on record types.
17pub trait WritePyRepr {
18    /// Whether this type's fields should be flattened into the parent repr.
19    const SHOULD_FLATTEN: bool = false;
20    /// Writes a Python-style string representation to `s`.
21    ///
22    /// # Errors
23    /// This function returns an error if it fails to expand the buffer to fit
24    /// the string.
25    fn write_py_repr(&self, s: &mut String) -> fmt::Result;
26}
27
28macro_rules! impl_write_py_repr_debug {
29    ($($ty:ty),+ $(,)?) => {
30        $(
31            impl WritePyRepr for $ty {
32                fn write_py_repr(&self, s: &mut String) -> fmt::Result {
33                    write!(s, "{self:?}")
34                }
35            }
36        )+
37    };
38}
39
40impl_write_py_repr_debug! {
41    i64, u64, i32, u32, i16, u16, i8, u8, bool,
42    FlagSet,
43}
44
45impl WritePyRepr for RecordHeader {
46    const SHOULD_FLATTEN: bool = true;
47
48    fn write_py_repr(&self, s: &mut String) -> fmt::Result {
49        write!(s, "rtype=")?;
50        match self.rtype() {
51            Ok(rtype) => rtype.write_py_repr(s)?,
52            Err(_) => write!(s, "{}", self.rtype)?,
53        }
54        write!(s, ", publisher_id=")?;
55        match self.publisher() {
56            Ok(p) => p.write_py_repr(s)?,
57            Err(_) => write!(s, "{}", self.publisher_id)?,
58        }
59        write!(s, ", instrument_id={}, ", self.instrument_id)?;
60        fmt_ts(s, "ts_event", self.ts_event)
61    }
62}
63
64impl<const N: usize> WritePyRepr for [c_char; N] {
65    fn write_py_repr(&self, s: &mut String) -> fmt::Result {
66        match c_chars_to_str(self) {
67            Ok(v) => write!(s, "'{v}'"),
68            Err(_) => write!(s, "{self:?}"),
69        }
70    }
71}
72
73impl WritePyRepr for &str {
74    fn write_py_repr(&self, s: &mut String) -> fmt::Result {
75        write!(s, "'{self}'")
76    }
77}
78
79impl<const N: usize> WritePyRepr for [BidAskPair; N] {
80    const SHOULD_FLATTEN: bool = true;
81
82    fn write_py_repr(&self, s: &mut String) -> fmt::Result {
83        // Flatten array with indexed field names, including both raw and pretty prices
84        for (i, level) in self.iter().enumerate() {
85            if i > 0 {
86                write!(s, ", ")?;
87            }
88            // bid_px raw then pretty
89            write!(
90                s,
91                "bid_px_{i:02}={}, pretty_bid_px_{i:02}={}, ask_px_{i:02}={}, pretty_ask_px_{i:02}={}, bid_sz_{i:02}={}, ask_sz_{i:02}={}, bid_ct_{i:02}={}, ask_ct_{i:02}={}",
92                level.bid_px,
93                pretty::px_to_f64(level.bid_px),
94                level.ask_px,
95                pretty::px_to_f64(level.ask_px),
96                level.bid_sz,
97                level.ask_sz,
98                level.bid_ct,
99                level.ask_ct
100            )?;
101        }
102        Ok(())
103    }
104}
105
106impl<const N: usize> WritePyRepr for [ConsolidatedBidAskPair; N] {
107    const SHOULD_FLATTEN: bool = true;
108
109    fn write_py_repr(&self, s: &mut String) -> fmt::Result {
110        // Flatten array with indexed field names, including both raw and pretty prices
111        for (i, level) in self.iter().enumerate() {
112            if i > 0 {
113                write!(s, ", ")?;
114            }
115            write!(
116                s,
117                "bid_px_{i:02}={}, pretty_bid_px_{i:02}={}, ask_px_{i:02}={}, pretty_ask_px_{i:02}={}, bid_sz_{i:02}={}, ask_sz_{i:02}={}, bid_pb_{i:02}={}, ask_pb_{i:02}={}",
118                level.bid_px,
119                pretty::px_to_f64(level.bid_px),
120                level.ask_px,
121                pretty::px_to_f64(level.ask_px),
122                level.bid_sz,
123                level.ask_sz,
124                level.bid_pb,
125                level.ask_pb
126            )?;
127        }
128        Ok(())
129    }
130}
131
132/// Formats a fixed-precision price field for a Python repr.
133///
134/// # Errors
135/// This function returns an error if it fails to expand the buffer to fit
136/// the string.
137pub fn fmt_px(s: &mut String, field_name: &str, px: i64) -> fmt::Result {
138    write!(s, "{field_name}={px}, ")?;
139    write!(s, "pretty_{field_name}={}", pretty::px_to_f64(px))
140}
141
142/// Formats a nanosecond UNIX timestamp field for a Python repr.
143///
144/// # Errors
145/// This function returns an error if it fails to expand the buffer to fit
146/// the string.
147pub fn fmt_ts(s: &mut String, field_name: &str, ts: u64) -> fmt::Result {
148    write!(s, "{field_name}={ts}, ")?;
149    write!(s, "pretty_{field_name}='{}'", pretty::fmt_ts(ts))
150}
151
152/// Format a `c_char` field that should be displayed as a Python enum.
153/// Falls back to char representation if parsing fails.
154///
155/// # Errors
156/// This function returns an error if it fails to expand the buffer to fit
157/// the string.
158pub fn fmt_c_char_enum<E, F>(
159    f: &mut String,
160    field_name: &str,
161    raw: c_char,
162    parser: F,
163) -> fmt::Result
164where
165    E: WritePyRepr,
166    F: FnOnce() -> crate::Result<E>,
167{
168    write!(f, "{field_name}=")?;
169    match parser() {
170        Ok(e) => e.write_py_repr(f),
171        Err(_) => write!(f, "'{}'", raw as u8 as char),
172    }
173}
174
175/// Format an enum value obtained via a method call.
176///
177/// # Errors
178/// This function returns an error if it fails to expand the buffer to fit
179/// the string.
180pub fn fmt_enum_method<E, F>(f: &mut String, field_name: &str, getter: F) -> fmt::Result
181where
182    E: WritePyRepr,
183    F: FnOnce() -> crate::Result<E>,
184{
185    write!(f, "{field_name}=")?;
186    match getter() {
187        Ok(e) => e.write_py_repr(f),
188        Err(_) => write!(f, "None"),
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::{CbboMsg, Mbp10Msg, Mbp1Msg, SType, StatMsg, SymbolMappingMsg, UNDEF_PRICE};
196
197    #[test]
198    fn test_fmt_px() {
199        let mut s = String::new();
200        fmt_px(&mut s, "price", 150_250_000_000).unwrap();
201        assert_eq!(s, "price=150250000000, pretty_price=150.25");
202    }
203
204    #[test]
205    fn test_fmt_px_undef() {
206        let mut s = String::new();
207        fmt_px(&mut s, "price", UNDEF_PRICE).unwrap();
208        assert_eq!(s, "price=9223372036854775807, pretty_price=NaN");
209    }
210
211    #[test]
212    fn test_flags_empty() {
213        let mut s = String::new();
214        FlagSet::empty().write_py_repr(&mut s).unwrap();
215        assert_eq!(s, "0");
216    }
217
218    #[test]
219    fn test_flags_set() {
220        let mut s = String::new();
221        let flags = FlagSet::empty().set_last().set_snapshot();
222        flags.write_py_repr(&mut s).unwrap();
223        assert_eq!(s, "LAST | SNAPSHOT (160)");
224    }
225
226    #[test]
227    fn test_bid_ask_pair_array_flattens() {
228        let levels = [BidAskPair {
229            bid_px: 100_250_000_000,
230            ask_px: 101_000_000_000,
231            bid_sz: 10,
232            ask_sz: 20,
233            bid_ct: 5,
234            ask_ct: 8,
235        }];
236        let mut s = String::new();
237        levels.write_py_repr(&mut s).unwrap();
238        assert_eq!(
239            s,
240            r"bid_px_00=100250000000, pretty_bid_px_00=100.25, ask_px_00=101000000000, pretty_ask_px_00=101, bid_sz_00=10, ask_sz_00=20, bid_ct_00=5, ask_ct_00=8"
241        );
242    }
243
244    #[test]
245    fn test_mbp1_msg_repr() {
246        let msg = Mbp1Msg {
247            hd: RecordHeader::new::<Mbp1Msg>(crate::rtype::MBP_1, 1, 12345, 1_000_000_000),
248            price: 150_250_000_000,
249            size: 100,
250            action: b'A' as i8,
251            side: b'B' as i8,
252            flags: FlagSet::empty().set_last(),
253            depth: 0,
254            ts_recv: 1_000_000_100,
255            ts_in_delta: 100,
256            sequence: 1,
257            levels: [BidAskPair {
258                bid_px: 150_000_000_000,
259                ask_px: 150_500_000_000,
260                bid_sz: 50,
261                ask_sz: 75,
262                bid_ct: 3,
263                ask_ct: 4,
264            }],
265        };
266        let mut s = String::new();
267        msg.write_py_repr(&mut s).unwrap();
268        assert_eq!(
269            s,
270            r"Mbp1Msg(ts_recv=1000000100, pretty_ts_recv='1970-01-01T00:00:01.000000100Z', rtype=<RType.MBP_1: 1>, publisher_id=GLBX.MDP3.GLBX (1), instrument_id=12345, ts_event=1000000000, pretty_ts_event='1970-01-01T00:00:01.000000000Z', action='A', side='B', depth=0, price=150250000000, pretty_price=150.25, size=100, flags=LAST (128), ts_in_delta=100, sequence=1, bid_px_00=150000000000, pretty_bid_px_00=150, ask_px_00=150500000000, pretty_ask_px_00=150.5, bid_sz_00=50, ask_sz_00=75, bid_ct_00=3, ask_ct_00=4)"
271        );
272    }
273
274    #[test]
275    fn test_symbol_mapping_msg_repr() {
276        let msg = SymbolMappingMsg::new(
277            12345,
278            1_704_067_200_000_000_000, // 2024-01-01 00:00Z
279            SType::RawSymbol,
280            "AAPL",
281            SType::InstrumentId,
282            "AAPL.XNAS",
283            1_704_067_200_000_000_000,
284            1_704_153_600_000_000_000, // 2024-01-02 00:00Z
285        )
286        .unwrap();
287        let mut s = String::new();
288        msg.write_py_repr(&mut s).unwrap();
289        // Check key parts of the repr
290        assert!(s.starts_with("SymbolMappingMsg("));
291        assert!(s.contains("stype_in_symbol='AAPL'"));
292        assert!(s.contains("stype_out_symbol='AAPL.XNAS'"));
293        assert!(s.contains("start_ts="));
294        assert!(s.contains("pretty_start_ts="));
295        assert!(s.contains("end_ts="));
296        assert!(s.contains("pretty_end_ts="));
297    }
298
299    #[test]
300    fn test_stat_msg_repr() {
301        let stat = StatMsg {
302            hd: RecordHeader::new::<StatMsg>(crate::rtype::STATISTICS, 1, 12345, 1_000_000_000),
303            ts_recv: 1_000_000_100,
304            ts_ref: 1_000_000_000,
305            price: 150_250_000_000,
306            quantity: 1000,
307            sequence: 42,
308            ts_in_delta: 100,
309            stat_type: 1,
310            channel_id: 0,
311            update_action: 1,
312            stat_flags: 0,
313            ..Default::default()
314        };
315        let mut s = String::new();
316        stat.write_py_repr(&mut s).unwrap();
317        assert!(s.starts_with("StatMsg("));
318        assert!(s.contains("price=150250000000, pretty_price=150.25"));
319        assert!(s.contains("ts_recv="));
320        assert!(s.contains("pretty_ts_recv="));
321        assert!(s.contains("ts_ref="));
322        assert!(s.contains("pretty_ts_ref="));
323    }
324
325    #[test]
326    fn test_cbbo_msg_repr() {
327        let cbbo = CbboMsg {
328            hd: RecordHeader::new::<CbboMsg>(crate::rtype::CBBO_1S, 1, 12345, 1_000_000_000),
329            price: 150_250_000_000,
330            size: 100,
331            side: b'B' as i8,
332            flags: FlagSet::empty().set_last(),
333            ts_recv: 1_000_000_100,
334            levels: [ConsolidatedBidAskPair {
335                bid_px: 150_000_000_000,
336                ask_px: 150_500_000_000,
337                bid_sz: 50,
338                ask_sz: 75,
339                bid_pb: 1,
340                ask_pb: 2,
341                ..Default::default()
342            }],
343            ..CbboMsg::default_for_schema(crate::Schema::Cbbo1S)
344        };
345        let mut s = String::new();
346        cbbo.write_py_repr(&mut s).unwrap();
347        assert!(s.starts_with("CbboMsg("));
348        assert!(s.contains("side='B'"));
349        assert!(s.contains("bid_px_00="));
350        assert!(s.contains("pretty_bid_px_00="));
351        assert!(s.contains("bid_pb_00="));
352        assert!(s.contains("ask_pb_00="));
353        // Hidden fields should not appear
354        assert!(!s.contains("_reserved"));
355    }
356
357    #[test]
358    fn test_mbp10_msg_repr() {
359        let mut mbp = Mbp10Msg {
360            hd: RecordHeader::new::<Mbp10Msg>(crate::rtype::MBP_10, 1, 12345, 1_000_000_000),
361            price: 150_250_000_000,
362            size: 100,
363            action: b'A' as i8,
364            side: b'B' as i8,
365            flags: FlagSet::empty().set_last(),
366            depth: 0,
367            ts_recv: 1_000_000_100,
368            ts_in_delta: 100,
369            sequence: 1,
370            ..Default::default()
371        };
372        mbp.levels[0] = BidAskPair {
373            bid_px: 150_000_000_000,
374            ask_px: 150_500_000_000,
375            bid_sz: 50,
376            ask_sz: 75,
377            bid_ct: 3,
378            ask_ct: 4,
379        };
380        mbp.levels[1] = BidAskPair {
381            bid_px: 149_750_000_000,
382            ask_px: 150_750_000_000,
383            bid_sz: 100,
384            ask_sz: 120,
385            bid_ct: 5,
386            ask_ct: 6,
387        };
388        let mut s = String::new();
389        mbp.write_py_repr(&mut s).unwrap();
390        assert!(s.starts_with("Mbp10Msg("));
391        assert!(s.contains("bid_px_00=150000000000"));
392        assert!(s.contains("pretty_bid_px_00=150"));
393        assert!(s.contains("bid_px_01=149750000000"));
394        assert!(s.contains("pretty_bid_px_01=149.75"));
395        assert!(s.contains("bid_px_09="));
396        assert!(s.contains("ask_ct_09="));
397    }
398}