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
76pub 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
87pub 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}