hypercall_types/
fill_accounting.rs1use crate::{utils::is_option_symbol, Fill, Side};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6pub struct FillAccounting {
7 pub trade_id: u64,
8 pub taker_realized_pnl: Decimal,
13 pub maker_realized_pnl: Decimal,
14 pub taker_premium_delta: Decimal,
16 pub maker_premium_delta: Decimal,
17 pub taker_net_cash_delta: Decimal,
19 pub maker_net_cash_delta: Decimal,
20}
21
22impl FillAccounting {
23 pub fn zero(trade_id: u64) -> Self {
24 Self {
25 trade_id,
26 ..Self::default()
27 }
28 }
29
30 pub fn from_fill(fill: &Fill) -> Self {
31 if !is_option_symbol(&fill.symbol) {
32 return Self::zero(fill.trade_id);
33 }
34
35 let size_human = crate::to_human_readable_decimal(&fill.symbol, fill.size);
36 let taker_premium_delta = premium_delta(fill.taker_side, fill.price, size_human);
37 let maker_premium_delta =
38 premium_delta(opposite_side(fill.taker_side), fill.price, size_human);
39
40 Self {
41 trade_id: fill.trade_id,
42 taker_realized_pnl: fill.taker_realized_pnl.unwrap_or(Decimal::ZERO),
43 maker_realized_pnl: fill.maker_realized_pnl.unwrap_or(Decimal::ZERO),
44 taker_premium_delta,
45 maker_premium_delta,
46 taker_net_cash_delta: taker_premium_delta,
47 maker_net_cash_delta: maker_premium_delta,
48 }
49 }
50
51 pub fn taker_premium_delta(&self) -> Decimal {
52 self.taker_premium_delta
53 }
54
55 pub fn maker_premium_delta(&self) -> Decimal {
56 self.maker_premium_delta
57 }
58
59 pub fn taker_net_cash_delta(&self) -> Decimal {
60 self.taker_net_cash_delta
61 }
62
63 pub fn maker_net_cash_delta(&self) -> Decimal {
64 self.maker_net_cash_delta
65 }
66
67 pub fn taker_ledger_residual_delta(&self) -> Decimal {
68 self.taker_net_cash_delta - self.taker_premium_delta
69 }
70
71 pub fn maker_ledger_residual_delta(&self) -> Decimal {
72 self.maker_net_cash_delta - self.maker_premium_delta
73 }
74
75 pub fn assert_cash_decomposition(&self) {
76 assert_eq!(
77 self.taker_ledger_residual_delta() + self.taker_premium_delta,
78 self.taker_net_cash_delta,
79 "CRITICAL: taker fill accounting decomposition mismatch for trade {}",
80 self.trade_id
81 );
82 assert_eq!(
83 self.maker_ledger_residual_delta() + self.maker_premium_delta,
84 self.maker_net_cash_delta,
85 "CRITICAL: maker fill accounting decomposition mismatch for trade {}",
86 self.trade_id
87 );
88 }
89}
90
91fn premium_delta(side: Side, fill_price: Decimal, size_human: Decimal) -> Decimal {
92 let gross_premium = fill_price * size_human;
93 match side {
94 Side::Buy => -gross_premium,
95 Side::Sell => gross_premium,
96 }
97}
98
99fn opposite_side(side: Side) -> Side {
100 match side {
101 Side::Buy => Side::Sell,
102 Side::Sell => Side::Buy,
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::wallet_address::test_wallet;
110 use rust_decimal_macros::dec;
111
112 fn make_fill(symbol: &str, taker_side: Side) -> Fill {
113 Fill {
114 trade_id: 42,
115 taker_order_id: 1,
116 maker_order_id: 2,
117 symbol: symbol.to_string(),
118 price: dec!(100),
119 size: dec!(2_000_000),
120 taker_side,
121 taker_wallet_address: test_wallet(1),
122 maker_wallet_address: test_wallet(2),
123 fee: Decimal::ZERO,
124 is_taker: true,
125 timestamp: 1_700_000_000_000,
126 builder_code_address: None,
127 builder_code_fee: None,
128 source: crate::FillSource::Orderbook,
129 taker_realized_pnl: None,
130 maker_realized_pnl: None,
131 underlying_notional: None,
132 }
133 }
134
135 #[test]
136 fn option_buy_sets_premium_signs_and_decomposition() {
137 let accounting = FillAccounting::from_fill(&make_fill("BTC-20260101-100000-C", Side::Buy));
138
139 assert_eq!(accounting.taker_premium_delta(), dec!(-200));
140 assert_eq!(accounting.maker_premium_delta(), dec!(200));
141 assert_eq!(accounting.taker_ledger_residual_delta(), Decimal::ZERO);
142 assert_eq!(accounting.maker_ledger_residual_delta(), Decimal::ZERO);
143 accounting.assert_cash_decomposition();
144 }
145
146 #[test]
147 fn option_sell_sets_premium_signs_and_decomposition() {
148 let accounting = FillAccounting::from_fill(&make_fill("BTC-20260101-100000-C", Side::Sell));
149
150 assert_eq!(accounting.taker_premium_delta(), dec!(200));
151 assert_eq!(accounting.maker_premium_delta(), dec!(-200));
152 assert_eq!(accounting.taker_net_cash_delta(), dec!(200));
153 assert_eq!(accounting.maker_net_cash_delta(), dec!(-200));
154 accounting.assert_cash_decomposition();
155 }
156
157 #[test]
158 fn realized_pnl_is_reported_separately_from_premium() {
159 let mut fill = make_fill("BTC-20260101-100000-C", Side::Buy);
160 fill.taker_realized_pnl = Some(dec!(-25));
161 fill.maker_realized_pnl = Some(dec!(25));
162
163 let accounting = FillAccounting::from_fill(&fill);
164
165 assert_eq!(accounting.taker_realized_pnl, dec!(-25));
166 assert_eq!(accounting.maker_realized_pnl, dec!(25));
167 assert_eq!(accounting.taker_ledger_residual_delta(), Decimal::ZERO);
168 assert_eq!(accounting.maker_ledger_residual_delta(), Decimal::ZERO);
169 accounting.assert_cash_decomposition();
170 }
171
172 #[test]
173 fn non_option_fill_produces_zero_accounting() {
174 let accounting = FillAccounting::from_fill(&make_fill("BTC-PERP", Side::Buy));
175
176 assert_eq!(accounting, FillAccounting::zero(42));
177 accounting.assert_cash_decomposition();
178 }
179}