Skip to main content

hypercall_api/
options_chain.rs

1use std::sync::Arc;
2
3use chrono::{Datelike, NaiveDate};
4use hypercall_types::WalletAddress;
5use rust_decimal::prelude::ToPrimitive;
6
7use super::models::{
8    Instrument, OptionsChainGreeksAbs, OptionsChainGreeksCash, OptionsChainLeg,
9    OptionsChainStrikeRow,
10};
11use crate::boundary::market_inputs::GreeksCacheReader;
12use hypercall_runtime_api::QuoteProvider;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OptionsChainOptionTypeFilter {
16    Call,
17    Put,
18    Both,
19}
20
21impl OptionsChainOptionTypeFilter {
22    pub fn parse(raw: Option<&str>) -> Result<Self, String> {
23        match raw.map(|value| value.trim().to_ascii_lowercase()) {
24            None => Ok(Self::Both),
25            Some(value) if value.is_empty() => Ok(Self::Both),
26            Some(value) if value == "call" => Ok(Self::Call),
27            Some(value) if value == "put" => Ok(Self::Put),
28            Some(value) if value == "both" => Ok(Self::Both),
29            Some(value) => Err(format!(
30                "Invalid option_type: {}. Expected one of: call, put, both",
31                value
32            )),
33        }
34    }
35
36    pub fn allows_option_type(self, option_type: &str) -> bool {
37        match self {
38            Self::Both => true,
39            Self::Call => option_type.eq_ignore_ascii_case("call"),
40            Self::Put => option_type.eq_ignore_ascii_case("put"),
41        }
42    }
43
44    pub fn allows_is_call(self, is_call: bool) -> bool {
45        match self {
46            Self::Both => true,
47            Self::Call => is_call,
48            Self::Put => !is_call,
49        }
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum OptionsChainSideFilter {
55    Buy,
56    Sell,
57    Both,
58}
59
60impl OptionsChainSideFilter {
61    pub fn parse(raw: Option<&str>) -> Result<Self, String> {
62        match raw.map(|value| value.trim().to_ascii_lowercase()) {
63            None => Ok(Self::Both),
64            Some(value) if value.is_empty() => Ok(Self::Both),
65            Some(value) if value == "buy" => Ok(Self::Buy),
66            Some(value) if value == "sell" => Ok(Self::Sell),
67            Some(value) if value == "both" => Ok(Self::Both),
68            Some(value) => Err(format!(
69                "Invalid side: {}. Expected one of: buy, sell, both",
70                value
71            )),
72        }
73    }
74}
75
76/// Parse a `YYYY-MM-DD` expiry date into a YYYYMMDD code.
77pub fn parse_expiry_date_to_code(expiry: &str) -> Result<u64, String> {
78    let date = NaiveDate::parse_from_str(expiry, "%Y-%m-%d").map_err(|_| {
79        format!(
80            "Invalid expiry date format: {}. Expected YYYY-MM-DD",
81            expiry
82        )
83    })?;
84    Ok((date.year() as u64 * 10_000) + (date.month() as u64 * 100) + date.day() as u64)
85}
86
87/// Parse a `YYYY-MM-DD` expiry date into the Unix timestamp at which the
88/// given currency's instruments expire on that date.
89pub fn parse_expiry_date_to_unix(currency: &str, expiry: &str) -> Result<u64, String> {
90    let expiry_code = parse_expiry_date_to_code(expiry)?;
91    hypercall_types::expiry_date_to_timestamp_checked(currency, expiry_code)
92        .map_err(|err| format!("Failed to convert expiry date {}: {}", expiry, err))
93}
94
95pub fn apply_side_filter_to_leg(leg: &mut OptionsChainLeg, side_filter: OptionsChainSideFilter) {
96    match side_filter {
97        OptionsChainSideFilter::Buy => {
98            leg.bid_price_usd = None;
99            leg.bid_iv = None;
100            leg.bid_size_contracts = None;
101            leg.bid_size_usd_notional = None;
102        }
103        OptionsChainSideFilter::Sell => {
104            leg.ask_price_usd = None;
105            leg.ask_iv = None;
106            leg.ask_size_contracts = None;
107            leg.ask_size_usd_notional = None;
108        }
109        OptionsChainSideFilter::Both => {}
110    }
111}
112
113pub fn compute_cash_greeks(
114    greeks_abs: &OptionsChainGreeksAbs,
115    spot_price: f64,
116) -> Option<OptionsChainGreeksCash> {
117    if spot_price <= 0.0 {
118        return None;
119    }
120
121    Some(OptionsChainGreeksCash {
122        delta_1pct_usd: greeks_abs.delta * spot_price * 0.01,
123        gamma_1pct_usd: 0.5 * greeks_abs.gamma * spot_price * spot_price * 0.01 * 0.01,
124        theta_1d_usd: greeks_abs.theta,
125        vega_1vol_usd: greeks_abs.vega,
126    })
127}
128
129pub async fn build_options_chain_leg(
130    instrument: &Instrument,
131    quote_provider: &Arc<dyn QuoteProvider>,
132    greeks_cache: &Arc<dyn GreeksCacheReader>,
133    side_filter: OptionsChainSideFilter,
134) -> OptionsChainLeg {
135    build_options_chain_leg_for_symbol(
136        &instrument.id,
137        &instrument.underlying,
138        instrument.option_token_address,
139        quote_provider,
140        greeks_cache,
141        side_filter,
142    )
143    .await
144}
145
146pub async fn build_options_chain_leg_for_symbol(
147    symbol: &str,
148    underlying: &str,
149    option_token_address: Option<WalletAddress>,
150    quote_provider: &Arc<dyn QuoteProvider>,
151    greeks_cache: &Arc<dyn GreeksCacheReader>,
152    side_filter: OptionsChainSideFilter,
153) -> OptionsChainLeg {
154    let quote = quote_provider.get_quote(symbol);
155
156    let bid_price = quote.as_ref().and_then(|value| value.best_bid);
157    let bid_size = quote.as_ref().and_then(|value| value.best_bid_size);
158    let ask_price = quote.as_ref().and_then(|value| value.best_ask);
159    let ask_size = quote.as_ref().and_then(|value| value.best_ask_size);
160
161    let greeks_abs = match greeks_cache.get_greeks(symbol).await {
162        Ok(greeks) => Some(OptionsChainGreeksAbs {
163            delta: greeks.delta,
164            gamma: greeks.gamma,
165            theta: greeks.theta,
166            vega: greeks.vega,
167        }),
168        Err(_) => None,
169    };
170
171    let (bid_iv, ask_iv) = greeks_cache
172        .get_quote_side_ivs_from_prices(symbol, bid_price, ask_price)
173        .await
174        .unwrap_or((None, None));
175
176    let greeks_cash = if let (Some(abs), Some(spot_price)) = (
177        greeks_abs.as_ref(),
178        greeks_cache.get_spot_price(underlying).await,
179    ) {
180        compute_cash_greeks(abs, spot_price)
181    } else {
182        None
183    };
184
185    let mut leg = OptionsChainLeg {
186        symbol: symbol.to_string(),
187        option_token_address,
188        bid_price_usd: bid_price,
189        bid_iv,
190        bid_size_contracts: bid_size,
191        bid_size_usd_notional: bid_price.zip(bid_size).map(|(price, size)| price * size),
192        ask_price_usd: ask_price,
193        ask_iv,
194        ask_size_contracts: ask_size,
195        ask_size_usd_notional: ask_price.zip(ask_size).map(|(price, size)| price * size),
196        greeks_abs,
197        greeks_cash,
198    };
199
200    apply_side_filter_to_leg(&mut leg, side_filter);
201    leg
202}
203
204pub fn strike_to_f64(strike: rust_decimal::Decimal) -> Result<f64, String> {
205    strike
206        .to_f64()
207        .ok_or_else(|| format!("Invalid strike value: {}", strike))
208}
209
210pub fn build_single_leg_strike_row(
211    strike: f64,
212    option_type: &str,
213    leg: OptionsChainLeg,
214) -> Result<OptionsChainStrikeRow, String> {
215    if option_type.eq_ignore_ascii_case("call") {
216        return Ok(OptionsChainStrikeRow {
217            strike,
218            call: Some(leg),
219            put: None,
220        });
221    }
222
223    if option_type.eq_ignore_ascii_case("put") {
224        return Ok(OptionsChainStrikeRow {
225            strike,
226            call: None,
227            put: Some(leg),
228        });
229    }
230
231    Err(format!("Invalid option type '{}'", option_type))
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn parse_expiry_date_to_unix_defaults_to_0800_utc() {
240        assert_eq!(
241            parse_expiry_date_to_unix("BTC", "2025-06-20").unwrap(),
242            1_750_406_400
243        );
244    }
245
246    #[test]
247    fn parse_expiry_date_to_code_roundtrips() {
248        assert_eq!(parse_expiry_date_to_code("2025-06-20").unwrap(), 20250620);
249        assert!(parse_expiry_date_to_code("2025-6-20x").is_err());
250    }
251}