Skip to main content

hypercall/pnl_attribution/
serde_codec.rs

1//! Msgpack encoding/decoding for PnL attribution data.
2
3use rust_decimal::prelude::ToPrimitive;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use super::Attribution;
9use super::SymbolAttribution;
10
11/// Compact serializable representation for msgpack storage.
12#[derive(Debug, Serialize, Deserialize)]
13struct CompactAttribution {
14    /// Map of symbol -> [position, entry_price, realized, unrealized, total]
15    s: HashMap<String, [f64; 5]>,
16}
17
18/// Encode an Attribution to msgpack bytes.
19pub fn encode_attribution(attr: &Attribution) -> Vec<u8> {
20    let compact = CompactAttribution {
21        s: attr
22            .by_symbol
23            .iter()
24            .map(|(symbol, sa)| {
25                (
26                    symbol.clone(),
27                    [
28                        sa.position.to_f64().unwrap_or(0.0),
29                        sa.entry_price.to_f64().unwrap_or(0.0),
30                        sa.realized_pnl.to_f64().unwrap_or(0.0),
31                        sa.unrealized_pnl.to_f64().unwrap_or(0.0),
32                        sa.total_pnl.to_f64().unwrap_or(0.0),
33                    ],
34                )
35            })
36            .collect(),
37    };
38    rmp_serde::to_vec(&compact).expect("msgpack encoding should not fail")
39}
40
41/// Decode msgpack bytes back to an Attribution.
42pub fn decode_attribution(bytes: &[u8]) -> Result<Attribution, rmp_serde::decode::Error> {
43    let compact: CompactAttribution = rmp_serde::from_slice(bytes)?;
44    let mut by_symbol = HashMap::with_capacity(compact.s.len());
45    let mut total_pnl = rust_decimal_macros::dec!(0);
46
47    for (symbol, vals) in compact.s {
48        let sa = SymbolAttribution {
49            position: Decimal::from_f64_retain(vals[0]).unwrap_or_default(),
50            entry_price: Decimal::from_f64_retain(vals[1]).unwrap_or_default(),
51            realized_pnl: Decimal::from_f64_retain(vals[2]).unwrap_or_default(),
52            unrealized_pnl: Decimal::from_f64_retain(vals[3]).unwrap_or_default(),
53            total_pnl: Decimal::from_f64_retain(vals[4]).unwrap_or_default(),
54        };
55        total_pnl += sa.total_pnl;
56        by_symbol.insert(symbol, sa);
57    }
58
59    Ok(Attribution {
60        by_symbol,
61        total_pnl,
62    })
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use rust_decimal_macros::dec;
69
70    #[test]
71    fn roundtrip_empty() {
72        let attr = Attribution {
73            by_symbol: HashMap::new(),
74            total_pnl: dec!(0),
75        };
76        let bytes = encode_attribution(&attr);
77        let decoded = decode_attribution(&bytes).unwrap();
78        assert!(decoded.by_symbol.is_empty());
79    }
80
81    #[test]
82    fn roundtrip_with_data() {
83        let mut by_symbol = HashMap::new();
84        by_symbol.insert(
85            "ETH-20260414-2300-C".to_string(),
86            SymbolAttribution {
87                position: dec!(12575.8),
88                entry_price: dec!(1.20),
89                realized_pnl: dec!(0),
90                unrealized_pnl: dec!(918662.19),
91                total_pnl: dec!(918662.19),
92            },
93        );
94        by_symbol.insert(
95            "HYPE-20260424-39-P".to_string(),
96            SymbolAttribution {
97                position: dec!(25403.6),
98                entry_price: dec!(1.20),
99                realized_pnl: dec!(0),
100                unrealized_pnl: dec!(-12447.764),
101                total_pnl: dec!(-12447.764),
102            },
103        );
104        let attr = Attribution {
105            by_symbol,
106            total_pnl: dec!(906214.426),
107        };
108
109        let bytes = encode_attribution(&attr);
110        // Should be compact
111        assert!(
112            bytes.len() < 200,
113            "msgpack should be compact, got {} bytes",
114            bytes.len()
115        );
116
117        let decoded = decode_attribution(&bytes).unwrap();
118        assert_eq!(decoded.by_symbol.len(), 2);
119        assert!(decoded.by_symbol.contains_key("ETH-20260414-2300-C"));
120        assert!(decoded.by_symbol.contains_key("HYPE-20260424-39-P"));
121
122        // f64 roundtrip loses some precision, check within tolerance
123        let eth = &decoded.by_symbol["ETH-20260414-2300-C"];
124        assert!((eth.unrealized_pnl - dec!(918662.19)).abs() < dec!(1));
125    }
126
127    #[test]
128    fn compact_size() {
129        // 55 symbols (average wallet) should be well under 4KB
130        let mut by_symbol = HashMap::new();
131        for i in 0..55 {
132            by_symbol.insert(
133                format!("SYM-2026041{}-{}-C", i % 30, 1000 + i),
134                SymbolAttribution {
135                    position: dec!(100),
136                    entry_price: dec!(1.5),
137                    realized_pnl: dec!(50),
138                    unrealized_pnl: dec!(200),
139                    total_pnl: dec!(250),
140                },
141            );
142        }
143        let attr = Attribution {
144            by_symbol,
145            total_pnl: dec!(13750),
146        };
147
148        let bytes = encode_attribution(&attr);
149        assert!(
150            bytes.len() < 4096,
151            "55-symbol attribution should be < 4KB, got {} bytes",
152            bytes.len()
153        );
154    }
155}