Skip to main content

hypercall_types/
fill_accounting.rs

1use 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    /// Entry-vs-exit realized PnL for the reducing portion of this fill.
9    ///
10    /// For options this is report-only: cash is premium-settled on every fill,
11    /// so applying this as an additional cash delta would double-count.
12    pub taker_realized_pnl: Decimal,
13    pub maker_realized_pnl: Decimal,
14    /// Option premium cashflow. Buyers pay, sellers receive.
15    pub taker_premium_delta: Decimal,
16    pub maker_premium_delta: Decimal,
17    /// Actual account cash movement emitted by this option accounting path.
18    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}