hypercall/pnl_attribution/
serde_codec.rs1use 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#[derive(Debug, Serialize, Deserialize)]
13struct CompactAttribution {
14 s: HashMap<String, [f64; 5]>,
16}
17
18pub 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
41pub 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 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 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 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}