1use rust_decimal::Decimal;
53use rust_decimal::RoundingStrategy;
54use rust_decimal_macros::dec;
55use serde::{Deserialize, Serialize};
56
57mod api;
58mod error;
59mod events;
60mod persistence;
61mod planning;
62mod symbol;
63
64pub use api::{normalize_payout_ids, SettlementPayoutView};
65pub use error::SettlementError;
66pub use events::{PositionExpiredMessage, SettlementEconomics};
67pub use persistence::{
68 settlement_cash_delta_for_margin_mode, validate_settlement_economics,
69 validate_settlement_value, SettlementPersistenceIntent,
70};
71pub use planning::{build_position_expired_message, plan_position_settlements, SettlementPosition};
72pub use symbol::{ParsedSettlementSymbol, SettlementInstrument};
73
74const SCALE_DP: u32 = 8;
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub enum OptionType {
79 Call,
80 Put,
81}
82
83impl From<hypercall_types::OptionType> for OptionType {
84 fn from(option_type: hypercall_types::OptionType) -> Self {
85 match option_type {
86 hypercall_types::OptionType::Call => Self::Call,
87 hypercall_types::OptionType::Put => Self::Put,
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct SettlementInput {
95 pub option_type: OptionType,
96 pub strike: Decimal,
98 pub reference_price: Decimal,
100 pub position_size: Decimal,
102 pub entry_price: Decimal,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SettlementOutput {
109 pub intrinsic_value: Decimal,
111 pub settlement_value: Decimal,
113 pub cost_basis: Decimal,
115 pub net_pnl: Decimal,
117}
118
119fn quantize(value: Decimal) -> Decimal {
120 value.round_dp_with_strategy(SCALE_DP, RoundingStrategy::MidpointAwayFromZero)
121}
122
123pub fn intrinsic_value(
128 option_type: OptionType,
129 strike: Decimal,
130 reference_price: Decimal,
131) -> Decimal {
132 match option_type {
133 OptionType::Call => (reference_price - strike).max(dec!(0)),
134 OptionType::Put => (strike - reference_price).max(dec!(0)),
135 }
136}
137
138pub fn settle_position(input: &SettlementInput) -> SettlementOutput {
143 let iv = intrinsic_value(input.option_type, input.strike, input.reference_price);
144 let settlement_value = iv * input.position_size;
145 let cost_basis = quantize(input.position_size * input.entry_price);
146 let net_pnl = quantize(settlement_value - cost_basis);
147
148 SettlementOutput {
149 intrinsic_value: iv,
150 settlement_value,
151 cost_basis,
152 net_pnl,
153 }
154}
155
156pub fn settle_batch(inputs: &[SettlementInput]) -> (Vec<SettlementOutput>, Decimal) {
161 let mut outputs = Vec::with_capacity(inputs.len());
162 let mut total_pnl = Decimal::ZERO;
163 for input in inputs {
164 let out = settle_position(input);
165 total_pnl += out.net_pnl;
166 outputs.push(out);
167 }
168 (outputs, total_pnl)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use rust_decimal_macros::dec;
175
176 #[test]
177 fn call_itm() {
178 let iv = intrinsic_value(OptionType::Call, dec!(100), dec!(130));
179 assert_eq!(iv, dec!(30));
180 }
181
182 #[test]
183 fn call_otm() {
184 let iv = intrinsic_value(OptionType::Call, dec!(100), dec!(80));
185 assert_eq!(iv, dec!(0));
186 }
187
188 #[test]
189 fn call_atm() {
190 let iv = intrinsic_value(OptionType::Call, dec!(100), dec!(100));
191 assert_eq!(iv, dec!(0));
192 }
193
194 #[test]
195 fn put_itm() {
196 let iv = intrinsic_value(OptionType::Put, dec!(100), dec!(70));
197 assert_eq!(iv, dec!(30));
198 }
199
200 #[test]
201 fn put_otm() {
202 let iv = intrinsic_value(OptionType::Put, dec!(100), dec!(130));
203 assert_eq!(iv, dec!(0));
204 }
205
206 #[test]
207 fn put_atm() {
208 let iv = intrinsic_value(OptionType::Put, dec!(100), dec!(100));
209 assert_eq!(iv, dec!(0));
210 }
211
212 #[test]
213 fn long_call_settlement() {
214 let out = settle_position(&SettlementInput {
215 option_type: OptionType::Call,
216 strike: dec!(100),
217 reference_price: dec!(130),
218 position_size: dec!(2),
219 entry_price: dec!(100),
220 });
221 assert_eq!(out.intrinsic_value, dec!(30));
222 assert_eq!(out.settlement_value, dec!(60));
223 assert_eq!(out.cost_basis, dec!(200));
224 assert_eq!(out.net_pnl, dec!(-140));
225 }
226
227 #[test]
228 fn short_put_settlement() {
229 let out = settle_position(&SettlementInput {
230 option_type: OptionType::Put,
231 strike: dec!(100),
232 reference_price: dec!(70),
233 position_size: dec!(-3),
234 entry_price: dec!(120),
235 });
236 assert_eq!(out.intrinsic_value, dec!(30));
237 assert_eq!(out.settlement_value, dec!(-90));
238 assert_eq!(out.cost_basis, dec!(-360));
239 assert_eq!(out.net_pnl, dec!(270));
240 }
241
242 #[test]
243 fn parses_settlement_symbol_and_expiry_timestamp() {
244 let parsed = ParsedSettlementSymbol::from_symbol("BTC-20250115-100000-C")
245 .expect("symbol should parse");
246 assert_eq!(parsed.underlying, "BTC");
247 assert_eq!(parsed.expiry, 20250115);
248 assert_eq!(parsed.expiry_ts, 1736928000);
249 assert_eq!(parsed.strike, dec!(100000));
250 assert_eq!(parsed.option_type, OptionType::Call);
251 }
252
253 #[test]
254 fn accepts_one_or_two_digit_deribit_day() {
255 let one_digit = ParsedSettlementSymbol::from_symbol("BTC-1JAN25-100000-C")
256 .expect("one-digit day should parse");
257 let two_digit = ParsedSettlementSymbol::from_symbol("BTC-01JAN25-100000-C")
258 .expect("two-digit day should parse");
259
260 assert_eq!(one_digit.expiry, 20250101);
261 assert_eq!(two_digit.expiry, 20250101);
262 }
263
264 #[test]
265 fn rejects_malformed_deribit_expiry() {
266 assert!(ParsedSettlementSymbol::from_symbol("BTC-01JAN2025-100000-C").is_err());
267 assert!(ParsedSettlementSymbol::from_symbol("BTC-00JAN25-100000-C").is_err());
268 assert!(ParsedSettlementSymbol::from_symbol("BTC-001JAN25-100000-C").is_err());
269 }
270
271 #[test]
272 fn rejects_partial_settlement_economics() {
273 let err = validate_settlement_economics(Some(dec!(1)), None, Some(dec!(2)), "test")
274 .expect_err("partial economics should fail");
275 assert!(err.message().contains("Partial settlement economics tuple"));
276 }
277
278 #[test]
279 fn builds_persistence_intent_from_position_expired() {
280 let wallet = hypercall_types::WalletAddress::from([7u8; 20]);
281 let message = PositionExpiredMessage {
282 wallet_address: wallet,
283 margin_mode: hypercall_types::MarginMode::Standard,
284 symbol: "BTC-20250115-100000-C".to_string(),
285 position_size: dec!(2),
286 settlement_price: dec!(5000),
287 settlement_value: dec!(10000),
288 settlement_entry_price: Some(dec!(3000)),
289 cost_basis: Some(dec!(6000)),
290 net_pnl: Some(dec!(4000)),
291 timestamp: 1736928000000,
292 };
293
294 let intent = SettlementPersistenceIntent::from_position_expired(&message)
295 .expect("intent should build");
296 assert_eq!(intent.wallet, wallet);
297 assert_eq!(intent.margin_mode, hypercall_types::MarginMode::Standard);
298 assert_eq!(intent.expiry_ts, 1736928000);
299 assert_eq!(intent.settlement_value, dec!(10000));
300 assert_eq!(intent.economics.expect("economics").net_pnl, dec!(4000));
301 }
302
303 #[test]
304 fn rejects_inconsistent_persistence_intent_cashflow() {
305 let wallet = hypercall_types::WalletAddress::from([7u8; 20]);
306 let message = PositionExpiredMessage {
307 wallet_address: wallet,
308 margin_mode: hypercall_types::MarginMode::Standard,
309 symbol: "BTC-20250115-100000-C".to_string(),
310 position_size: dec!(2),
311 settlement_price: dec!(5000),
312 settlement_value: dec!(9999),
313 settlement_entry_price: None,
314 cost_basis: None,
315 net_pnl: None,
316 timestamp: 1736928000000,
317 };
318
319 let err = SettlementPersistenceIntent::from_position_expired(&message)
320 .expect_err("inconsistent settlement value should fail");
321 assert!(err.message().contains("Settlement value mismatch"));
322 }
323
324 #[test]
325 fn rejects_overflowing_persistence_intent_cashflow() {
326 let err = validate_settlement_value(Decimal::MAX, dec!(2), Decimal::ZERO, "overflow")
327 .expect_err("overflowing settlement value should fail");
328 assert!(err.message().contains("Settlement value overflow"));
329 }
330
331 #[test]
332 fn computes_margin_mode_cash_delta() {
333 assert_eq!(
334 settlement_cash_delta_for_margin_mode(
335 hypercall_types::MarginMode::Standard,
336 dec!(5000),
337 Some(dec!(4000)),
338 "standard",
339 )
340 .expect("standard delta"),
341 dec!(5000)
342 );
343 assert_eq!(
344 settlement_cash_delta_for_margin_mode(
345 hypercall_types::MarginMode::Portfolio,
346 dec!(5000),
347 Some(dec!(4000)),
348 "portfolio",
349 )
350 .expect("portfolio delta"),
351 dec!(4000)
352 );
353 assert!(settlement_cash_delta_for_margin_mode(
354 hypercall_types::MarginMode::Portfolio,
355 dec!(5000),
356 None,
357 "portfolio",
358 )
359 .is_err());
360 }
361
362 #[test]
363 fn normalizes_payout_ids() {
364 let ids = normalize_payout_ids(&[3, 1, 3, 2]).expect("ids should normalize");
365 assert_eq!(ids, vec![3, 1, 3, 2]);
366 assert!(normalize_payout_ids(&[]).is_err());
367 assert!(normalize_payout_ids(&[1, 0]).is_err());
368 }
369
370 #[test]
371 fn computes_settlement_economics() {
372 let out = settle_position(&SettlementInput {
373 option_type: OptionType::Call,
374 strike: dec!(0),
375 reference_price: dec!(130),
376 position_size: dec!(2),
377 entry_price: dec!(100),
378 });
379 assert_eq!(out.cost_basis, dec!(200));
380 assert_eq!(out.net_pnl, dec!(60));
381 }
382
383 #[test]
384 fn quantizes_to_eight_decimals() {
385 let out = settle_position(&SettlementInput {
386 option_type: OptionType::Call,
387 strike: dec!(0),
388 reference_price: dec!(1),
389 position_size: dec!(1.000000005),
390 entry_price: dec!(1.000000005),
391 });
392 assert_eq!(out.cost_basis.scale(), 8);
393 assert_eq!(out.net_pnl.scale(), 8);
394 }
395
396 #[test]
397 fn otm_call_zero_pnl() {
398 let out = settle_position(&SettlementInput {
399 option_type: OptionType::Call,
400 strike: dec!(100),
401 reference_price: dec!(80),
402 position_size: dec!(5),
403 entry_price: dec!(10),
404 });
405 assert_eq!(out.intrinsic_value, dec!(0));
406 assert_eq!(out.settlement_value, dec!(0));
407 assert_eq!(out.cost_basis, dec!(50));
408 assert_eq!(out.net_pnl, dec!(-50));
409 }
410
411 #[test]
412 fn zero_sum_long_short_pair() {
413 let strike = dec!(100);
414 let reference_price = dec!(130);
415 let entry_price = dec!(15);
416
417 let long = settle_position(&SettlementInput {
418 option_type: OptionType::Call,
419 strike,
420 reference_price,
421 position_size: dec!(1),
422 entry_price,
423 });
424 let short = settle_position(&SettlementInput {
425 option_type: OptionType::Call,
426 strike,
427 reference_price,
428 position_size: dec!(-1),
429 entry_price,
430 });
431
432 assert_eq!(long.net_pnl + short.net_pnl, dec!(0));
433 assert_eq!(long.settlement_value + short.settlement_value, dec!(0));
434 assert_eq!(long.cost_basis + short.cost_basis, dec!(0));
435 }
436
437 #[test]
438 fn zero_sum_batch() {
439 let inputs = vec![
440 SettlementInput {
441 option_type: OptionType::Call,
442 strike: dec!(100),
443 reference_price: dec!(130),
444 position_size: dec!(5),
445 entry_price: dec!(12),
446 },
447 SettlementInput {
448 option_type: OptionType::Call,
449 strike: dec!(100),
450 reference_price: dec!(130),
451 position_size: dec!(-3),
452 entry_price: dec!(12),
453 },
454 SettlementInput {
455 option_type: OptionType::Call,
456 strike: dec!(100),
457 reference_price: dec!(130),
458 position_size: dec!(-2),
459 entry_price: dec!(12),
460 },
461 ];
462
463 let (_, total_pnl) = settle_batch(&inputs);
464 assert_eq!(total_pnl, dec!(0));
465 }
466}
467
468#[cfg(kani)]
469mod verification {
470 use super::*;
471
472 fn any_decimal() -> Decimal {
475 let mantissa: i64 = kani::any();
476 let scale: u32 = kani::any();
477 kani::assume(scale <= 8);
478 kani::assume(mantissa.checked_abs().is_some());
479 Decimal::new(mantissa, scale)
480 }
481
482 fn any_positive_decimal() -> Decimal {
483 let mantissa: i64 = kani::any();
484 let scale: u32 = kani::any();
485 kani::assume(scale <= 8);
486 kani::assume(mantissa > 0);
487 Decimal::new(mantissa, scale)
488 }
489
490 fn any_decimal_fast() -> Decimal {
493 let mantissa: i16 = kani::any();
494 let scale: u32 = kani::any();
495 kani::assume(scale <= 4);
496 Decimal::new(mantissa as i64, scale)
497 }
498
499 fn any_positive_decimal_fast() -> Decimal {
500 let mantissa: i16 = kani::any();
501 let scale: u32 = kani::any();
502 kani::assume(scale <= 4);
503 kani::assume(mantissa > 0);
504 Decimal::new(mantissa as i64, scale)
505 }
506
507 fn any_option_type() -> OptionType {
508 if kani::any() {
509 OptionType::Call
510 } else {
511 OptionType::Put
512 }
513 }
514
515 #[kani::proof]
521 fn fast_settlement_smoke() {
522 let out = settle_position(&SettlementInput {
523 option_type: OptionType::Call,
524 strike: Decimal::new(100, 0),
525 reference_price: Decimal::new(125, 0),
526 position_size: Decimal::new(2, 0),
527 entry_price: Decimal::new(10, 0),
528 });
529
530 assert_eq!(out.settlement_value, Decimal::new(50, 0));
531 assert_eq!(out.cost_basis, Decimal::new(20, 0));
532 assert_eq!(out.net_pnl, Decimal::new(30, 0));
533 }
534
535 #[kani::proof]
542 #[kani::unwind(2)]
543 fn bounded_intrinsic_non_negative() {
544 let ot = any_option_type();
545 let iv = intrinsic_value(ot, any_positive_decimal_fast(), any_positive_decimal_fast());
546 assert!(iv >= Decimal::ZERO);
547 }
548
549 #[kani::proof]
550 #[kani::unwind(2)]
551 fn bounded_call_put_parity() {
552 let strike = any_positive_decimal_fast();
553 let reference = any_positive_decimal_fast();
554 let call_iv = intrinsic_value(OptionType::Call, strike, reference);
555 let put_iv = intrinsic_value(OptionType::Put, strike, reference);
556 assert_eq!(call_iv - put_iv, reference - strike);
557 }
558
559 #[kani::proof]
560 #[kani::unwind(2)]
561 fn bounded_zero_sum_pnl() {
562 let ot = any_option_type();
563 let strike = any_positive_decimal_fast();
564 let reference = any_positive_decimal_fast();
565 let size = any_decimal_fast();
566 let entry = any_decimal_fast();
567
568 let long = settle_position(&SettlementInput {
569 option_type: ot,
570 strike,
571 reference_price: reference,
572 position_size: size,
573 entry_price: entry,
574 });
575 let short = settle_position(&SettlementInput {
576 option_type: ot,
577 strike,
578 reference_price: reference,
579 position_size: -size,
580 entry_price: entry,
581 });
582
583 assert_eq!(
584 long.settlement_value + short.settlement_value,
585 Decimal::ZERO
586 );
587 assert_eq!(long.cost_basis + short.cost_basis, Decimal::ZERO);
588 assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
589 }
590
591 #[kani::proof]
592 #[kani::unwind(2)]
593 fn bounded_no_panic_settle() {
594 let _out = settle_position(&SettlementInput {
595 option_type: any_option_type(),
596 strike: any_positive_decimal_fast(),
597 reference_price: any_positive_decimal_fast(),
598 position_size: any_decimal_fast(),
599 entry_price: any_decimal_fast(),
600 });
601 }
602
603 #[kani::proof]
608 #[kani::unwind(2)]
609 fn full_intrinsic_non_negative() {
610 let ot = any_option_type();
611 let iv = intrinsic_value(ot, any_positive_decimal(), any_positive_decimal());
612 assert!(iv >= Decimal::ZERO);
613 }
614
615 #[kani::proof]
616 #[kani::unwind(2)]
617 fn full_call_put_parity() {
618 let strike = any_positive_decimal();
619 let reference = any_positive_decimal();
620 let call_iv = intrinsic_value(OptionType::Call, strike, reference);
621 let put_iv = intrinsic_value(OptionType::Put, strike, reference);
622 assert_eq!(call_iv - put_iv, reference - strike);
623 }
624
625 #[kani::proof]
626 #[kani::unwind(2)]
627 fn full_zero_sum_pnl() {
628 let ot = any_option_type();
629 let strike = any_positive_decimal();
630 let reference = any_positive_decimal();
631 let size = any_decimal();
632 let entry = any_decimal();
633
634 let long = settle_position(&SettlementInput {
635 option_type: ot,
636 strike,
637 reference_price: reference,
638 position_size: size,
639 entry_price: entry,
640 });
641 let short = settle_position(&SettlementInput {
642 option_type: ot,
643 strike,
644 reference_price: reference,
645 position_size: -size,
646 entry_price: entry,
647 });
648
649 assert_eq!(
650 long.settlement_value + short.settlement_value,
651 Decimal::ZERO
652 );
653 assert_eq!(long.cost_basis + short.cost_basis, Decimal::ZERO);
654 assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
655 }
656
657 #[kani::proof]
658 #[kani::unwind(2)]
659 fn full_zero_position_zero_pnl() {
660 let ot = any_option_type();
661 let entry = any_decimal();
662 let out = settle_position(&SettlementInput {
663 option_type: ot,
664 strike: any_positive_decimal(),
665 reference_price: any_positive_decimal(),
666 position_size: Decimal::ZERO,
667 entry_price: entry,
668 });
669 assert_eq!(out.settlement_value, Decimal::ZERO);
670 assert_eq!(out.cost_basis, Decimal::ZERO);
671 assert_eq!(out.net_pnl, Decimal::ZERO);
672 }
673
674 #[kani::proof]
675 #[kani::unwind(2)]
676 fn full_otm_zero_intrinsic() {
677 let strike = any_positive_decimal();
678 let reference = any_positive_decimal();
679 kani::assume(reference <= strike);
680 assert_eq!(
681 intrinsic_value(OptionType::Call, strike, reference),
682 Decimal::ZERO
683 );
684 }
685
686 #[kani::proof]
687 #[kani::unwind(2)]
688 fn full_itm_equals_spread() {
689 let strike = any_positive_decimal();
690 let reference = any_positive_decimal();
691 kani::assume(strike <= reference);
692 assert_eq!(
693 intrinsic_value(OptionType::Call, strike, reference),
694 reference - strike
695 );
696 }
697
698 #[kani::proof]
699 #[kani::unwind(2)]
700 fn full_no_panic_settle() {
701 let _out = settle_position(&SettlementInput {
702 option_type: any_option_type(),
703 strike: any_positive_decimal(),
704 reference_price: any_positive_decimal(),
705 position_size: any_decimal(),
706 entry_price: any_decimal(),
707 });
708 }
709}
710
711#[cfg(test)]
712mod proptests {
713 use super::*;
714 use proptest::prelude::*;
715
716 fn decimal_strategy(lo: i64, hi: i64) -> impl Strategy<Value = Decimal> {
717 (lo..=hi).prop_map(|v| Decimal::new(v, 2))
718 }
719
720 proptest! {
721 #[test]
722 fn call_intrinsic_non_negative(
723 strike in decimal_strategy(1, 100000),
724 reference in decimal_strategy(1, 100000),
725 ) {
726 let iv = intrinsic_value(OptionType::Call, strike, reference);
727 prop_assert!(iv >= Decimal::ZERO);
728 }
729
730 #[test]
731 fn put_intrinsic_non_negative(
732 strike in decimal_strategy(1, 100000),
733 reference in decimal_strategy(1, 100000),
734 ) {
735 let iv = intrinsic_value(OptionType::Put, strike, reference);
736 prop_assert!(iv >= Decimal::ZERO);
737 }
738
739 #[test]
740 fn call_put_parity(
741 strike in decimal_strategy(1, 100000),
742 reference in decimal_strategy(1, 100000),
743 ) {
744 let call_iv = intrinsic_value(OptionType::Call, strike, reference);
745 let put_iv = intrinsic_value(OptionType::Put, strike, reference);
746 prop_assert_eq!(call_iv - put_iv, reference - strike);
747 }
748
749 #[test]
750 fn zero_sum_matched_pair(
751 strike in decimal_strategy(1, 100000),
752 reference in decimal_strategy(1, 100000),
753 size in decimal_strategy(1, 10000),
754 entry in decimal_strategy(1, 10000),
755 ) {
756 let long = settle_position(&SettlementInput {
757 option_type: OptionType::Call,
758 strike,
759 reference_price: reference,
760 position_size: size,
761 entry_price: entry,
762 });
763 let short = settle_position(&SettlementInput {
764 option_type: OptionType::Call,
765 strike,
766 reference_price: reference,
767 position_size: -size,
768 entry_price: entry,
769 });
770 prop_assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
771 prop_assert_eq!(long.settlement_value + short.settlement_value, Decimal::ZERO);
772 }
773
774 #[test]
775 fn zero_sum_matched_put_pair(
776 strike in decimal_strategy(1, 100000),
777 reference in decimal_strategy(1, 100000),
778 size in decimal_strategy(1, 10000),
779 entry in decimal_strategy(1, 10000),
780 ) {
781 let long = settle_position(&SettlementInput {
782 option_type: OptionType::Put,
783 strike,
784 reference_price: reference,
785 position_size: size,
786 entry_price: entry,
787 });
788 let short = settle_position(&SettlementInput {
789 option_type: OptionType::Put,
790 strike,
791 reference_price: reference,
792 position_size: -size,
793 entry_price: entry,
794 });
795 prop_assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
796 }
797 }
798}