Skip to main content

acta_maker_sdk/
spreads.rs

1//! Cash spreads use quote atoms for collateral and payoff; prices use 1e9.
2
3use thiserror::Error;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6#[repr(u8)]
7pub enum SpreadType {
8    CallSpread = 2,
9    PutSpread = 3,
10}
11
12impl TryFrom<u8> for SpreadType {
13    type Error = SpreadError;
14    fn try_from(value: u8) -> Result<Self, Self::Error> {
15        match value {
16            2 => Ok(Self::CallSpread),
17            3 => Ok(Self::PutSpread),
18            _ => Err(SpreadError::InvalidType),
19        }
20    }
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
24pub enum SpreadError {
25    #[error("taker_buy_unsupported")]
26    TakerBuyUnsupported,
27    #[error("expected CallSpread or PutSpread")]
28    InvalidType,
29    #[error("spread requires lower < upper")]
30    InvalidStrikes,
31    #[error("spread requires nonzero quantity and 0 < premium < width")]
32    InvalidPremium,
33    #[error("spread amount overflow or unsupported decimals (expected 0..=18)")]
34    Overflow,
35    #[error("invalid spread fee basis points")]
36    InvalidFee,
37    #[error("collateral is below the required liability")]
38    InsufficientCollateral,
39    #[error("invalid pricing inputs or long-leg values")]
40    InvalidPricing,
41}
42
43pub fn width(lower: u64, upper: u64) -> Result<u64, SpreadError> {
44    upper
45        .checked_sub(lower)
46        .filter(|v| *v > 0)
47        .ok_or(SpreadError::InvalidStrikes)
48}
49
50/// Put spreads allow a zero lower strike; calls require a positive lower strike.
51pub fn validate_strikes(kind: SpreadType, lower: u64, upper: u64) -> Result<u64, SpreadError> {
52    if kind == SpreadType::CallSpread && lower == 0 {
53        return Err(SpreadError::InvalidStrikes);
54    }
55    width(lower, upper)
56}
57
58pub fn validate_premium(
59    kind: SpreadType,
60    lower: u64,
61    upper: u64,
62    quantity: u64,
63    price: u64,
64) -> Result<(), SpreadError> {
65    let width = validate_strikes(kind, lower, upper)?;
66    if quantity == 0 || price == 0 || price >= width {
67        return Err(SpreadError::InvalidPremium);
68    }
69    Ok(())
70}
71
72pub fn quote_amount(
73    price: u64,
74    quantity: u64,
75    quote_decimals: u8,
76    underlying_decimals: u8,
77    ceil: bool,
78) -> Result<u64, SpreadError> {
79    if quote_decimals > 18 || underlying_decimals > 18 {
80        return Err(SpreadError::Overflow);
81    }
82    let quote_scale = 10u128.pow(u32::from(quote_decimals));
83    let denominator = 1_000_000_000u128 * 10u128.pow(u32::from(underlying_decimals));
84    let numerator = u128::from(price) * u128::from(quantity);
85    let result = if quote_scale <= denominator {
86        let divisor = denominator / quote_scale;
87        numerator / divisor + u128::from(ceil && numerator % divisor != 0)
88    } else {
89        numerator
90            .checked_mul(quote_scale / denominator)
91            .ok_or(SpreadError::Overflow)?
92    };
93    u64::try_from(result).map_err(|_| SpreadError::Overflow)
94}
95
96pub fn collateral(
97    lower: u64,
98    upper: u64,
99    quantity: u64,
100    quote_decimals: u8,
101    underlying_decimals: u8,
102) -> Result<u64, SpreadError> {
103    quote_amount(
104        width(lower, upper)?,
105        quantity,
106        quote_decimals,
107        underlying_decimals,
108        true,
109    )
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct SpreadTerms {
114    pub kind: SpreadType,
115    pub lower_strike: u64,
116    pub upper_strike: u64,
117    pub quantity: u64,
118    pub quote_decimals: u8,
119    pub underlying_decimals: u8,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct SpreadPremium {
124    pub collateral: u64,
125    pub gross_premium: u64,
126    pub protocol_fee: u64,
127    pub net_premium: u64,
128    pub prefunded_quote: u64,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct SpreadSettlement {
133    pub maker_payoff: u64,
134    pub taker_return: u64,
135}
136
137impl SpreadTerms {
138    pub fn collateral(self) -> Result<u64, SpreadError> {
139        collateral(
140            self.lower_strike,
141            self.upper_strike,
142            self.quantity,
143            self.quote_decimals,
144            self.underlying_decimals,
145        )
146    }
147
148    pub fn payoff(self, settlement_price: u64) -> Result<u64, SpreadError> {
149        let width = width(self.lower_strike, self.upper_strike)?;
150        let intrinsic = match self.kind {
151            SpreadType::CallSpread => settlement_price.saturating_sub(self.lower_strike),
152            SpreadType::PutSpread => self.upper_strike.saturating_sub(settlement_price),
153        }
154        .min(width);
155        self.quote(intrinsic, true)
156    }
157
158    pub fn settle(
159        self,
160        settlement_price: u64,
161        actual_collateral: u64,
162    ) -> Result<SpreadSettlement, SpreadError> {
163        if actual_collateral < self.collateral()? {
164            return Err(SpreadError::InsufficientCollateral);
165        }
166        let maker_payoff = self.payoff(settlement_price)?;
167        let taker_return = actual_collateral
168            .checked_sub(maker_payoff)
169            .ok_or(SpreadError::InsufficientCollateral)?;
170        Ok(SpreadSettlement {
171            maker_payoff,
172            taker_return,
173        })
174    }
175
176    pub fn premium(
177        self,
178        price: u64,
179        premium_bps: u16,
180        volume_bps: u16,
181    ) -> Result<SpreadPremium, SpreadError> {
182        validate_premium(
183            self.kind,
184            self.lower_strike,
185            self.upper_strike,
186            self.quantity,
187            price,
188        )?;
189        if premium_bps > 10_000 || volume_bps > 10_000 {
190            return Err(SpreadError::InvalidFee);
191        }
192        let collateral = self.collateral()?;
193        let gross_premium = self.quote(price, false)?;
194        let exposure = self.quote(width(self.lower_strike, self.upper_strike)?, false)?;
195        if gross_premium == 0 || gross_premium >= collateral || exposure == 0 {
196            return Err(SpreadError::InvalidPremium);
197        }
198        let fee = |amount: u64, bps: u16| -> Result<u64, SpreadError> {
199            u64::try_from(u128::from(amount) * u128::from(bps) / 10_000)
200                .map_err(|_| SpreadError::Overflow)
201        };
202        let premium_fee = gross_premium - self.quote(price - fee(price, premium_bps)?, false)?;
203        let protocol_fee = premium_fee.min(fee(exposure, volume_bps)?);
204        let net_premium = gross_premium - protocol_fee;
205        Ok(SpreadPremium {
206            collateral,
207            gross_premium,
208            protocol_fee,
209            net_premium,
210            prefunded_quote: collateral - net_premium,
211        })
212    }
213
214    fn quote(self, price: u64, ceil: bool) -> Result<u64, SpreadError> {
215        quote_amount(
216            price,
217            self.quantity,
218            self.quote_decimals,
219            self.underlying_decimals,
220            ceil,
221        )
222    }
223}
224
225/// Both long legs are valued by the caller's model with one expiry/volatility context.
226#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct SpreadPricingContext {
228    pub time_to_expiry_years: f64,
229    pub volatility: f64,
230}
231
232pub fn price_spread(
233    kind: SpreadType,
234    lower_strike: f64,
235    upper_strike: f64,
236    context: SpreadPricingContext,
237    mut long_leg_value: impl FnMut(bool, f64, SpreadPricingContext) -> f64,
238) -> Result<f64, SpreadError> {
239    if !lower_strike.is_finite()
240        || !upper_strike.is_finite()
241        || lower_strike < 0.0
242        || (kind == SpreadType::CallSpread && lower_strike == 0.0)
243        || upper_strike <= lower_strike
244        || !context.time_to_expiry_years.is_finite()
245        || context.time_to_expiry_years < 0.0
246        || !context.volatility.is_finite()
247        || context.volatility < 0.0
248    {
249        return Err(SpreadError::InvalidPricing);
250    }
251    let is_put = kind == SpreadType::PutSpread;
252    let low = if is_put && lower_strike == 0.0 {
253        0.0
254    } else {
255        long_leg_value(is_put, lower_strike, context)
256    };
257    let high = long_leg_value(is_put, upper_strike, context);
258    let price = if is_put { high - low } else { low - high };
259    if !low.is_finite()
260        || !high.is_finite()
261        || low < 0.0
262        || high < 0.0
263        || !price.is_finite()
264        || price < 0.0
265        || price > upper_strike - lower_strike
266    {
267        return Err(SpreadError::InvalidPricing);
268    }
269    Ok(price)
270}