Skip to main content

hypercall_engine/
accounting.rs

1use crate::position::{EnginePosition, EnginePositionMap};
2use hypercall_types::{utils::is_option_symbol, Fill, Side, WalletAddress};
3use rust_decimal::Decimal;
4use std::collections::HashMap;
5
6// FillAccounting is now defined in hypercall-types and re-exported from here.
7pub use hypercall_types::FillAccounting;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct FillAccountingPosition {
11    pub quantity: Decimal,
12    pub entry_price: Decimal,
13}
14
15impl From<&EnginePosition> for FillAccountingPosition {
16    fn from(position: &EnginePosition) -> Self {
17        Self {
18            quantity: position.quantity,
19            entry_price: position.entry_price,
20        }
21    }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FillCashSettlement {
26    OptionPremium,
27    RealizedPnl,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct FillAccountingContext {
32    pub taker_position: Option<FillAccountingPosition>,
33    pub maker_position: Option<FillAccountingPosition>,
34    pub taker_cash_settlement: FillCashSettlement,
35    pub maker_cash_settlement: FillCashSettlement,
36}
37
38impl FillAccountingContext {
39    pub fn option_premium() -> Self {
40        Self {
41            taker_position: None,
42            maker_position: None,
43            taker_cash_settlement: FillCashSettlement::OptionPremium,
44            maker_cash_settlement: FillCashSettlement::OptionPremium,
45        }
46    }
47}
48
49pub fn apply_fill_accounting(
50    positions: &mut EnginePositionMap,
51    cash_balances: &mut HashMap<WalletAddress, Decimal>,
52    fill: &Fill,
53) -> FillAccounting {
54    let accounting = apply_fill_position_accounting(positions, fill);
55
56    apply_cash_delta(
57        cash_balances,
58        fill.taker_wallet_address,
59        accounting.taker_net_cash_delta,
60    );
61    apply_cash_delta(
62        cash_balances,
63        fill.maker_wallet_address,
64        accounting.maker_net_cash_delta,
65    );
66
67    accounting
68}
69
70pub fn apply_fill_position_accounting(
71    positions: &mut EnginePositionMap,
72    fill: &Fill,
73) -> FillAccounting {
74    assert_option_fill(&fill.symbol);
75
76    let taker_position = positions
77        .get(&(fill.taker_wallet_address, fill.symbol.clone()))
78        .map(FillAccountingPosition::from);
79    let maker_position = positions
80        .get(&(fill.maker_wallet_address, fill.symbol.clone()))
81        .map(FillAccountingPosition::from);
82    let accounting = calculate_fill_accounting(
83        fill,
84        FillAccountingContext {
85            taker_position,
86            maker_position,
87            taker_cash_settlement: FillCashSettlement::OptionPremium,
88            maker_cash_settlement: FillCashSettlement::OptionPremium,
89        },
90    );
91
92    let size_human = hypercall_types::to_human_readable_decimal(&fill.symbol, fill.size);
93    let taker_signed_qty = signed_fill_qty(fill.taker_side, size_human);
94    let maker_signed_qty = -taker_signed_qty;
95    let applied_taker_realized_pnl = apply_position_fill(
96        positions,
97        fill.taker_wallet_address,
98        &fill.symbol,
99        taker_signed_qty,
100        fill.price,
101    );
102    let applied_maker_realized_pnl = apply_position_fill(
103        positions,
104        fill.maker_wallet_address,
105        &fill.symbol,
106        maker_signed_qty,
107        fill.price,
108    );
109
110    assert_eq!(
111        applied_taker_realized_pnl, accounting.taker_realized_pnl,
112        "CRITICAL: taker position apply disagrees with fill accounting for trade {}",
113        fill.trade_id
114    );
115    assert_eq!(
116        applied_maker_realized_pnl, accounting.maker_realized_pnl,
117        "CRITICAL: maker position apply disagrees with fill accounting for trade {}",
118        fill.trade_id
119    );
120
121    accounting
122}
123
124pub fn calculate_fill_accounting(fill: &Fill, context: FillAccountingContext) -> FillAccounting {
125    let size_human = hypercall_types::to_human_readable_decimal(&fill.symbol, fill.size);
126    let maker_side = opposite_side(fill.taker_side);
127    let taker_premium_delta = match context.taker_cash_settlement {
128        FillCashSettlement::OptionPremium if is_option_symbol(&fill.symbol) => {
129            fill_premium_delta(fill.taker_side, fill.price, size_human)
130        }
131        FillCashSettlement::OptionPremium => Decimal::ZERO,
132        FillCashSettlement::RealizedPnl => Decimal::ZERO,
133    };
134    let maker_premium_delta = match context.maker_cash_settlement {
135        FillCashSettlement::OptionPremium if is_option_symbol(&fill.symbol) => {
136            fill_premium_delta(maker_side, fill.price, size_human)
137        }
138        FillCashSettlement::OptionPremium => Decimal::ZERO,
139        FillCashSettlement::RealizedPnl => Decimal::ZERO,
140    };
141    let taker_realized_pnl = calculate_realized_pnl(
142        context.taker_position,
143        fill.taker_side,
144        fill.price,
145        size_human,
146    );
147    let maker_realized_pnl =
148        calculate_realized_pnl(context.maker_position, maker_side, fill.price, size_human);
149
150    let accounting = FillAccounting {
151        trade_id: fill.trade_id,
152        taker_realized_pnl,
153        maker_realized_pnl,
154        taker_premium_delta,
155        maker_premium_delta,
156        taker_net_cash_delta: net_cash_delta(
157            context.taker_cash_settlement,
158            taker_premium_delta,
159            taker_realized_pnl,
160        ),
161        maker_net_cash_delta: net_cash_delta(
162            context.maker_cash_settlement,
163            maker_premium_delta,
164            maker_realized_pnl,
165        ),
166    };
167    accounting.assert_cash_decomposition();
168    accounting
169}
170
171fn apply_position_fill(
172    positions: &mut EnginePositionMap,
173    wallet: WalletAddress,
174    symbol: &str,
175    signed_qty: Decimal,
176    fill_price: Decimal,
177) -> Decimal {
178    let key = (wallet, symbol.to_string());
179    let position = positions.entry(key.clone()).or_insert(EnginePosition {
180        quantity: Decimal::ZERO,
181        entry_price: Decimal::ZERO,
182    });
183    let realized_pnl = position.apply_fill(signed_qty, fill_price);
184    if position.quantity == Decimal::ZERO {
185        positions.remove(&key);
186    }
187    realized_pnl
188}
189
190fn apply_cash_delta(
191    cash_balances: &mut HashMap<WalletAddress, Decimal>,
192    wallet: WalletAddress,
193    delta: Decimal,
194) {
195    if delta == Decimal::ZERO {
196        return;
197    }
198
199    let balance = cash_balances.entry(wallet).or_insert(Decimal::ZERO);
200    *balance += delta;
201}
202
203pub fn fill_premium_delta(side: Side, fill_price: Decimal, size_human: Decimal) -> Decimal {
204    let gross_premium = fill_price * size_human;
205    match side {
206        Side::Buy => -gross_premium,
207        Side::Sell => gross_premium,
208    }
209}
210
211fn signed_fill_qty(side: Side, size_human: Decimal) -> Decimal {
212    match side {
213        Side::Buy => size_human,
214        Side::Sell => -size_human,
215    }
216}
217
218fn opposite_side(side: Side) -> Side {
219    match side {
220        Side::Buy => Side::Sell,
221        Side::Sell => Side::Buy,
222    }
223}
224
225fn calculate_realized_pnl(
226    position: Option<FillAccountingPosition>,
227    side: Side,
228    fill_price: Decimal,
229    fill_quantity: Decimal,
230) -> Decimal {
231    let Some(position) = position else {
232        return Decimal::ZERO;
233    };
234
235    let engine_position = EnginePosition {
236        quantity: position.quantity,
237        entry_price: position.entry_price,
238    };
239    EnginePosition::fill_transition(
240        Some(&engine_position),
241        signed_fill_qty(side, fill_quantity),
242        fill_price,
243    )
244    .realized_pnl
245}
246
247fn net_cash_delta(
248    settlement: FillCashSettlement,
249    premium_delta: Decimal,
250    realized_pnl: Decimal,
251) -> Decimal {
252    match settlement {
253        FillCashSettlement::OptionPremium => premium_delta,
254        FillCashSettlement::RealizedPnl => realized_pnl,
255    }
256}
257
258fn assert_option_fill(symbol: &str) {
259    assert!(
260        is_option_symbol(symbol),
261        "CRITICAL: option fill accounting only supports option symbols, got {symbol}"
262    );
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use hypercall_types::wallet_address::test_wallet;
269    use rust_decimal_macros::dec;
270
271    fn make_fill(
272        symbol: &str,
273        taker_side: Side,
274        price: Decimal,
275        size: Decimal,
276        taker: WalletAddress,
277        maker: WalletAddress,
278    ) -> Fill {
279        Fill {
280            trade_id: 42,
281            taker_order_id: 1,
282            maker_order_id: 2,
283            symbol: symbol.to_string(),
284            price,
285            size,
286            taker_side,
287            taker_wallet_address: taker,
288            maker_wallet_address: maker,
289            fee: Decimal::ZERO,
290            is_taker: true,
291            timestamp: 0,
292            builder_code_address: None,
293            builder_code_fee: None,
294            source: Default::default(),
295            taker_realized_pnl: None,
296            maker_realized_pnl: None,
297            underlying_notional: Some(size.abs() * price),
298        }
299    }
300
301    fn seeded_cash(taker: WalletAddress, maker: WalletAddress) -> HashMap<WalletAddress, Decimal> {
302        HashMap::from([(taker, dec!(10000)), (maker, dec!(10000))])
303    }
304
305    #[test]
306    fn option_buy_debits_taker_and_credits_maker() {
307        let taker = test_wallet(1);
308        let maker = test_wallet(2);
309        let fill = make_fill(
310            "BTC-20261231-100000-C",
311            Side::Buy,
312            dec!(250),
313            dec!(1000000),
314            taker,
315            maker,
316        );
317        let mut positions = EnginePositionMap::new();
318        let mut cash = seeded_cash(taker, maker);
319        let accounting = apply_fill_accounting(&mut positions, &mut cash, &fill);
320
321        assert_eq!(accounting.taker_realized_pnl, Decimal::ZERO);
322        assert_eq!(accounting.maker_realized_pnl, Decimal::ZERO);
323        assert_eq!(accounting.taker_premium_delta, dec!(-250));
324        assert_eq!(accounting.maker_premium_delta, dec!(250));
325        assert_eq!(accounting.taker_net_cash_delta, dec!(-250));
326        assert_eq!(accounting.maker_net_cash_delta, dec!(250));
327        assert_eq!(cash[&taker], dec!(9750));
328        assert_eq!(cash[&maker], dec!(10250));
329    }
330
331    #[test]
332    fn option_sell_debits_maker_buyer_and_credits_taker_seller() {
333        let taker = test_wallet(1);
334        let maker = test_wallet(2);
335        let fill = make_fill(
336            "BTC-20261231-100000-C",
337            Side::Sell,
338            dec!(250),
339            dec!(1000000),
340            taker,
341            maker,
342        );
343        let mut positions = EnginePositionMap::new();
344        let mut cash = seeded_cash(taker, maker);
345        let accounting = apply_fill_accounting(&mut positions, &mut cash, &fill);
346
347        assert_eq!(accounting.taker_premium_delta, dec!(250));
348        assert_eq!(accounting.maker_premium_delta, dec!(-250));
349        assert_eq!(accounting.taker_net_cash_delta, dec!(250));
350        assert_eq!(accounting.maker_net_cash_delta, dec!(-250));
351        assert_eq!(cash[&taker], dec!(10250));
352        assert_eq!(cash[&maker], dec!(9750));
353    }
354
355    #[test]
356    fn from_fill_uses_option_premium_for_cash_delta() {
357        let taker = test_wallet(1);
358        let maker = test_wallet(2);
359        let mut fill = make_fill(
360            "BTC-20261231-100000-C",
361            Side::Buy,
362            dec!(250),
363            dec!(1000000),
364            taker,
365            maker,
366        );
367        fill.taker_realized_pnl = Some(dec!(10));
368        fill.maker_realized_pnl = Some(dec!(-10));
369
370        let accounting = FillAccounting::from_fill(&fill);
371
372        assert_eq!(accounting.taker_realized_pnl, dec!(10));
373        assert_eq!(accounting.maker_realized_pnl, dec!(-10));
374        assert_eq!(accounting.taker_premium_delta, dec!(-250));
375        assert_eq!(accounting.maker_premium_delta, dec!(250));
376        assert_eq!(accounting.taker_net_cash_delta, dec!(-250));
377        assert_eq!(accounting.maker_net_cash_delta, dec!(250));
378    }
379
380    #[test]
381    fn from_fill_zeroes_non_option_accounting() {
382        let taker = test_wallet(1);
383        let maker = test_wallet(2);
384        let mut fill = make_fill("BTC-PERP", Side::Buy, dec!(95000), dec!(1), taker, maker);
385        fill.taker_realized_pnl = Some(dec!(10));
386        fill.maker_realized_pnl = Some(dec!(-10));
387
388        assert_eq!(FillAccounting::from_fill(&fill), FillAccounting::zero(42));
389    }
390
391    #[test]
392    fn missing_cash_entry_materializes_zero_balance() {
393        let taker = test_wallet(1);
394        let maker = test_wallet(2);
395        let fill = make_fill(
396            "BTC-20261231-100000-C",
397            Side::Sell,
398            dec!(250),
399            dec!(1000000),
400            taker,
401            maker,
402        );
403        let mut positions = EnginePositionMap::new();
404        let mut cash = HashMap::new();
405
406        let accounting = apply_fill_accounting(&mut positions, &mut cash, &fill);
407
408        assert_eq!(accounting.taker_net_cash_delta, dec!(250));
409        assert_eq!(accounting.maker_net_cash_delta, dec!(-250));
410        assert_eq!(cash[&taker], dec!(250));
411        assert_eq!(cash[&maker], dec!(-250));
412    }
413
414    #[test]
415    fn option_close_reports_realized_pnl_but_cash_moves_by_premium() {
416        let taker = test_wallet(1);
417        let maker = test_wallet(2);
418        let symbol = "BTC-20261231-100000-C".to_string();
419        let fill = make_fill(&symbol, Side::Sell, dec!(6000), dec!(1000000), taker, maker);
420        let mut positions = EnginePositionMap::from([(
421            (taker, symbol.clone()),
422            EnginePosition {
423                quantity: dec!(1),
424                entry_price: dec!(5000),
425            },
426        )]);
427        let mut cash = seeded_cash(taker, maker);
428
429        let accounting = apply_fill_accounting(&mut positions, &mut cash, &fill);
430
431        assert_eq!(accounting.taker_realized_pnl, dec!(1000));
432        assert_eq!(accounting.taker_premium_delta, dec!(6000));
433        assert_eq!(accounting.taker_net_cash_delta, dec!(6000));
434        assert_eq!(cash[&taker], dec!(16000));
435    }
436
437    #[test]
438    fn pure_fill_accounting_selects_premium_or_realized_pnl_settlement() {
439        let taker = test_wallet(1);
440        let maker = test_wallet(2);
441        let symbol = "BTC-20261231-100000-C";
442        let fill = make_fill(symbol, Side::Sell, dec!(6000), dec!(1000000), taker, maker);
443        let long_position = FillAccountingPosition {
444            quantity: dec!(1),
445            entry_price: dec!(5000),
446        };
447
448        let premium_accounting = calculate_fill_accounting(
449            &fill,
450            FillAccountingContext {
451                taker_position: Some(long_position),
452                maker_position: None,
453                taker_cash_settlement: FillCashSettlement::OptionPremium,
454                maker_cash_settlement: FillCashSettlement::OptionPremium,
455            },
456        );
457        let realized_pnl_accounting = calculate_fill_accounting(
458            &fill,
459            FillAccountingContext {
460                taker_position: Some(long_position),
461                maker_position: None,
462                taker_cash_settlement: FillCashSettlement::RealizedPnl,
463                maker_cash_settlement: FillCashSettlement::RealizedPnl,
464            },
465        );
466
467        assert_eq!(premium_accounting.taker_realized_pnl, dec!(1000));
468        assert_eq!(premium_accounting.taker_premium_delta, dec!(6000));
469        assert_eq!(premium_accounting.taker_net_cash_delta, dec!(6000));
470        assert_eq!(realized_pnl_accounting.taker_realized_pnl, dec!(1000));
471        assert_eq!(realized_pnl_accounting.taker_premium_delta, Decimal::ZERO);
472        assert_eq!(realized_pnl_accounting.taker_net_cash_delta, dec!(1000));
473    }
474
475    #[test]
476    fn perp_fill_is_not_supported_by_option_accounting() {
477        let taker = test_wallet(1);
478        let maker = test_wallet(2);
479        let symbol = "BTC-PERP".to_string();
480        let fill = make_fill(&symbol, Side::Sell, dec!(96000), dec!(1), taker, maker);
481        let mut positions = EnginePositionMap::from([(
482            (taker, symbol),
483            EnginePosition {
484                quantity: dec!(1),
485                entry_price: dec!(95000),
486            },
487        )]);
488        let mut cash = seeded_cash(taker, maker);
489        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
490            apply_fill_accounting(&mut positions, &mut cash, &fill)
491        }));
492
493        assert!(result.is_err());
494        assert_eq!(cash[&taker], dec!(10000));
495    }
496
497    #[test]
498    fn chained_fills_use_updated_position_state() {
499        let taker = test_wallet(1);
500        let maker = test_wallet(2);
501        let symbol = "BTC-20261231-100000-C";
502        let mut positions = EnginePositionMap::new();
503        let mut cash = seeded_cash(taker, maker);
504
505        let open_fill = make_fill(symbol, Side::Buy, dec!(5000), dec!(1000000), taker, maker);
506        let close_fill = make_fill(symbol, Side::Sell, dec!(6000), dec!(1000000), taker, maker);
507
508        let open_accounting = apply_fill_accounting(&mut positions, &mut cash, &open_fill);
509        let close_accounting = apply_fill_accounting(&mut positions, &mut cash, &close_fill);
510
511        assert_eq!(open_accounting.taker_realized_pnl, Decimal::ZERO);
512        assert_eq!(close_accounting.taker_realized_pnl, dec!(1000));
513        assert_eq!(close_accounting.taker_net_cash_delta, dec!(6000));
514        assert_eq!(cash[&taker], dec!(11000));
515    }
516
517    #[test]
518    fn option_accounting_does_not_require_margin_mode() {
519        let taker = test_wallet(1);
520        let maker = test_wallet(2);
521        let fill = make_fill(
522            "BTC-20261231-100000-C",
523            Side::Buy,
524            dec!(250),
525            dec!(1000000),
526            taker,
527            maker,
528        );
529        let mut positions = EnginePositionMap::new();
530        let mut cash = seeded_cash(taker, maker);
531
532        let accounting = apply_fill_accounting(&mut positions, &mut cash, &fill);
533
534        assert_eq!(accounting.taker_premium_delta, dec!(-250));
535        assert_eq!(cash[&taker], dec!(9750));
536    }
537
538    #[test]
539    #[should_panic(expected = "option fill accounting only supports option symbols")]
540    fn malformed_symbol_panics() {
541        let taker = test_wallet(1);
542        let maker = test_wallet(2);
543        let fill = make_fill("BTC-BROKEN", Side::Buy, dec!(250), dec!(1), taker, maker);
544        let mut positions = EnginePositionMap::new();
545        let mut cash = seeded_cash(taker, maker);
546
547        let _ = apply_fill_accounting(&mut positions, &mut cash, &fill);
548    }
549}