1use crate::position::{EnginePosition, EnginePositionMap};
2use hypercall_types::{utils::is_option_symbol, Fill, Side, WalletAddress};
3use rust_decimal::Decimal;
4use std::collections::HashMap;
5
6pub 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}