Skip to main content

hypercall_sdk_types/
utils.rs

1//! Utility functions and constants.
2
3use crate::expiry_times::{expiry_times, ExpiryTime};
4use crate::OptionType;
5use chrono::NaiveDate;
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8
9/// Constant for contract unit conversion (6 decimal places).
10pub const CONTRACT_UNIT_MULTIPLIER: f64 = 1_000_000.0;
11
12/// Decimal constant for contract unit conversion (6 decimal places).
13pub const CONTRACT_UNIT_MULTIPLIER_DECIMAL: Decimal = dec!(1000000);
14
15/// Maximum significant figures allowed for prices.
16pub const MAX_PRICE_SIGNIFICANT_FIGURES: usize = 5;
17
18/// Decimal scaling factor used by option strikes in on-chain contracts.
19pub const STRIKE_SCALE_1E8: i128 = 100_000_000;
20
21/// Decimal representation of [`STRIKE_SCALE_1E8`].
22pub const STRIKE_SCALE_1E8_DECIMAL: Decimal = dec!(100000000);
23
24const MIN_YYYYMMDD: u64 = 10_000_101; // 1000-01-01
25const MAX_YYYYMMDD: u64 = 99_991_231; // 9999-12-31
26
27/// Error returned when converting a YYYYMMDD expiry code to a Unix timestamp.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ExpiryDateConversionError {
30    /// The raw expiry code is not a valid eight-digit YYYYMMDD value.
31    InvalidExpiryCode { expiry: u64 },
32    /// The parsed date components do not form a real calendar date.
33    InvalidExpiryDate { year: i32, month: u32, day: u32 },
34    /// The resulting timestamp is negative and cannot be represented as `u64`.
35    InvalidExpiryTimestamp { expiry: u64, timestamp: i64 },
36}
37
38impl std::fmt::Display for ExpiryDateConversionError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            Self::InvalidExpiryCode { expiry } => {
42                write!(f, "Invalid expiry code (expected YYYYMMDD): {}", expiry)
43            }
44            Self::InvalidExpiryDate { year, month, day } => write!(
45                f,
46                "Invalid expiry date components: {}-{}-{}",
47                year, month, day
48            ),
49            Self::InvalidExpiryTimestamp { expiry, timestamp } => write!(
50                f,
51                "Expiry timestamp must be non-negative unix time, got {} for code {}",
52                timestamp, expiry
53            ),
54        }
55    }
56}
57
58impl std::error::Error for ExpiryDateConversionError {}
59
60/// Error returned when scaling a strike to exact 1e8 integer precision.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum StrikeScaleError {
63    /// Strike must be strictly positive.
64    NonPositive { strike: Decimal },
65    /// Decimal multiplication overflowed.
66    Overflow { strike: Decimal },
67    /// Strike cannot be represented exactly at 1e8 precision.
68    PrecisionExceedsE8 { strike: Decimal },
69    /// The scaled value could not be converted to an unsigned integer.
70    IntegerConversion { strike: Decimal, scaled: Decimal },
71    /// The scaled result resolved to zero.
72    ZeroScaled { strike: Decimal },
73}
74
75impl std::fmt::Display for StrikeScaleError {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::NonPositive { strike } => write!(f, "Invalid strike {}: must be > 0", strike),
79            Self::Overflow { strike } => write!(f, "Strike scaling overflow for {}", strike),
80            Self::PrecisionExceedsE8 { strike } => write!(
81                f,
82                "Invalid strike {}: precision exceeds 8 decimals, cannot convert exactly to e8",
83                strike
84            ),
85            Self::IntegerConversion { strike, scaled } => write!(
86                f,
87                "Invalid strike {}: cannot convert scaled e8 value {} to unsigned integer",
88                strike, scaled
89            ),
90            Self::ZeroScaled { strike } => {
91                write!(f, "Invalid strike {}: strike_e8 resolved to zero", strike)
92            }
93        }
94    }
95}
96
97impl std::error::Error for StrikeScaleError {}
98
99/// Convert a YYYYMMDD expiry code to a Unix timestamp at an explicit UTC
100/// time of day.
101///
102/// Most callers should use [`expiry_date_to_timestamp_checked`], which
103/// resolves the time of day from the installed per-underlying policy. This
104/// variant exists for external-venue data with a fixed convention (e.g.
105/// Deribit instruments always expire 08:00 UTC regardless of our config).
106pub fn expiry_date_to_timestamp_at_time_checked(
107    expiry: u64,
108    time: ExpiryTime,
109) -> Result<u64, ExpiryDateConversionError> {
110    if !(MIN_YYYYMMDD..=MAX_YYYYMMDD).contains(&expiry) {
111        return Err(ExpiryDateConversionError::InvalidExpiryCode { expiry });
112    }
113
114    let year = (expiry / 10_000) as i32;
115    let month = ((expiry % 10_000) / 100) as u32;
116    let day = (expiry % 100) as u32;
117
118    let date = NaiveDate::from_ymd_opt(year, month, day)
119        .ok_or(ExpiryDateConversionError::InvalidExpiryDate { year, month, day })?;
120    let timestamp = date
121        .and_hms_opt(time.hour, time.minute, 0)
122        .expect("ExpiryTime is validated at construction")
123        .and_utc()
124        .timestamp();
125
126    u64::try_from(timestamp)
127        .map_err(|_| ExpiryDateConversionError::InvalidExpiryTimestamp { expiry, timestamp })
128}
129
130/// Convert a YYYYMMDD expiry code to a Unix timestamp using the underlying's
131/// configured expiry time of day (default 08:00 UTC).
132pub fn expiry_date_to_timestamp_checked(
133    underlying: &str,
134    expiry: u64,
135) -> Result<u64, ExpiryDateConversionError> {
136    expiry_date_to_timestamp_at_time_checked(expiry, expiry_times().for_underlying(underlying))
137}
138
139/// Convert a strike to an exact 1e8-scaled integer.
140pub fn strike_to_e8(strike: Decimal) -> Result<u128, StrikeScaleError> {
141    use rust_decimal::prelude::ToPrimitive;
142
143    if strike <= Decimal::ZERO {
144        return Err(StrikeScaleError::NonPositive { strike });
145    }
146
147    let scaled = strike
148        .checked_mul(STRIKE_SCALE_1E8_DECIMAL)
149        .ok_or(StrikeScaleError::Overflow { strike })?;
150
151    if !scaled.fract().is_zero() {
152        return Err(StrikeScaleError::PrecisionExceedsE8 { strike });
153    }
154
155    let strike_e8 = scaled
156        .to_u128()
157        .ok_or(StrikeScaleError::IntegerConversion { strike, scaled })?;
158    if strike_e8 == 0 {
159        return Err(StrikeScaleError::ZeroScaled { strike });
160    }
161
162    Ok(strike_e8)
163}
164
165/// Convert expiry date (YYYYMMDD format) to Unix timestamp.
166///
167/// # Arguments
168/// * `underlying` - Underlying symbol (e.g., "BTC"), used to resolve the
169///   configured expiry time of day
170/// * `expiry` - Expiry date in YYYYMMDD format (e.g., 20251231)
171///
172/// # Returns
173/// Unix timestamp at the underlying's configured UTC expiry time, or 0 if
174/// parsing fails.
175pub fn expiry_date_to_timestamp(underlying: &str, expiry: u64) -> i64 {
176    expiry_date_to_timestamp_checked(underlying, expiry)
177        .ok()
178        .and_then(|timestamp| i64::try_from(timestamp).ok())
179        .unwrap_or(0)
180}
181
182/// Convert contract units to human-readable size.
183///
184/// # Arguments
185/// * `symbol` - The trading symbol (e.g., "BTC-PERP", "ETH-PERP")
186/// * `size` - Size in contract units
187///
188/// # Returns
189/// Human-readable size (e.g., 1_000_000 -> 1.0 BTC)
190pub fn to_human_readable(_symbol: &str, size: u64) -> f64 {
191    // For now, all symbols use the same conversion
192    // In the future, we might have different multipliers per asset
193    (size as f64) / CONTRACT_UNIT_MULTIPLIER
194}
195
196/// Convert human-readable size to contract units.
197///
198/// # Arguments
199/// * `symbol` - The trading symbol (e.g., "BTC-PERP", "ETH-PERP")
200/// * `size` - Human-readable size (e.g., 1.0 BTC)
201///
202/// # Returns
203/// Size in contract units (e.g., 1.0 -> 1_000_000)
204pub fn to_contract_units(_symbol: &str, size: f64) -> u64 {
205    // For now, all symbols use the same conversion
206    // In the future, we might have different multipliers per asset
207    (size * CONTRACT_UNIT_MULTIPLIER) as u64
208}
209
210/// Convert contract units (Decimal) to human-readable size (Decimal).
211///
212/// # Arguments
213/// * `symbol` - The trading symbol (e.g., "BTC-PERP", "ETH-PERP")
214/// * `size` - Size in contract units (Decimal)
215///
216/// # Returns
217/// Human-readable size as Decimal (e.g., 1_000_000 -> 1.0 BTC)
218pub fn to_human_readable_decimal(_symbol: &str, size: Decimal) -> Decimal {
219    size / CONTRACT_UNIT_MULTIPLIER_DECIMAL
220}
221
222/// Convert human-readable size (Decimal) to contract units (Decimal).
223///
224/// # Arguments
225/// * `symbol` - The trading symbol (e.g., "BTC-PERP", "ETH-PERP")
226/// * `size` - Human-readable size as Decimal (e.g., 1.0 BTC)
227///
228/// # Returns
229/// Size in contract units as Decimal (e.g., 1.0 -> 1_000_000)
230pub fn to_contract_units_decimal(_symbol: &str, size: Decimal) -> Decimal {
231    size * CONTRACT_UNIT_MULTIPLIER_DECIMAL
232}
233
234/// Error type for decimal to f64 conversion failures.
235#[derive(Debug, Clone)]
236pub struct DecimalConversionError {
237    /// The value that failed to convert.
238    pub value: Decimal,
239    /// Context describing where the conversion occurred.
240    pub context: &'static str,
241}
242
243impl std::fmt::Display for DecimalConversionError {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        write!(
246            f,
247            "Failed to convert Decimal {} to f64 in context: {}",
248            self.value, self.context
249        )
250    }
251}
252
253impl std::error::Error for DecimalConversionError {}
254
255/// Convert Decimal to f64 for calculations, returning error on failure.
256///
257/// Use this for Black-Scholes and other calculations that require f64.
258/// This function explicitly propagates conversion errors instead of silently
259/// defaulting to 0.0, which could cause incorrect financial calculations.
260///
261/// # Arguments
262/// * `value` - The Decimal value to convert
263/// * `context` - A static string describing where this conversion occurs (for error messages)
264///
265/// # Returns
266/// * `Ok(f64)` - The converted value
267/// * `Err(DecimalConversionError)` - If the conversion failed
268pub fn decimal_to_f64_checked(
269    value: Decimal,
270    context: &'static str,
271) -> Result<f64, DecimalConversionError> {
272    use rust_decimal::prelude::ToPrimitive;
273    value
274        .to_f64()
275        .ok_or(DecimalConversionError { value, context })
276}
277
278/// Count significant figures in a price string.
279///
280/// Rules for counting significant figures:
281/// - Leading zeros after decimal point are NOT significant (0.00123 -> 3 sig figs)
282/// - Trailing zeros before decimal point are NOT significant (12300 -> 3 sig figs)
283/// - Trailing zeros after decimal point ARE significant (123.00 -> 5 sig figs)
284/// - All non-zero digits are significant
285pub fn count_significant_figures(price_str: &str) -> Result<usize, String> {
286    let trimmed = price_str.trim();
287
288    // Handle empty string
289    if trimmed.is_empty() {
290        return Err("Empty price string".to_string());
291    }
292
293    // Remove leading '+' or '-' sign
294    let unsigned = trimmed.trim_start_matches('+').trim_start_matches('-');
295
296    // Check if the string contains only valid characters
297    let valid_chars = unsigned.chars().all(|c| c.is_ascii_digit() || c == '.');
298    if !valid_chars {
299        return Err(format!("Invalid price string: {}", price_str));
300    }
301
302    // Handle zero specially
303    if unsigned == "0" || unsigned == "0." || unsigned == "0.0" {
304        return Ok(1);
305    }
306
307    // Check for decimal point
308    if unsigned.contains('.') {
309        // With decimal point: remove leading zeros, count all remaining digits
310        let parts: Vec<&str> = unsigned.split('.').collect();
311        if parts.len() != 2 {
312            return Err(format!("Invalid decimal format: {}", price_str));
313        }
314
315        let integer_part = parts[0];
316        let decimal_part = parts[1];
317
318        // Remove leading zeros from integer part
319        let integer_trimmed = integer_part.trim_start_matches('0');
320
321        if integer_trimmed.is_empty() {
322            // Number like 0.00123 - count from first non-zero digit
323            let first_nonzero = decimal_part.find(|c: char| c != '0');
324            match first_nonzero {
325                Some(pos) => {
326                    // Count digits from first non-zero to end
327                    Ok(decimal_part[pos..].len())
328                }
329                None => {
330                    // All zeros like 0.000
331                    Ok(1)
332                }
333            }
334        } else {
335            // Number like 123.45 - count all digits in both parts
336            Ok(integer_trimmed.len() + decimal_part.len())
337        }
338    } else {
339        // No decimal point: remove leading and trailing zeros
340        let trimmed_leading = unsigned.trim_start_matches('0');
341
342        if trimmed_leading.is_empty() {
343            // All zeros
344            return Ok(1);
345        }
346
347        // Remove trailing zeros
348        let trimmed_both = trimmed_leading.trim_end_matches('0');
349
350        if trimmed_both.is_empty() {
351            // Number like 10000 - only one significant figure
352            return Ok(1);
353        }
354
355        Ok(trimmed_both.len())
356    }
357}
358
359/// Validate that a price has at most the specified number of significant figures.
360///
361/// # Arguments
362/// * `price` - The price to validate (as Decimal)
363/// * `max_sig_figs` - Maximum allowed significant figures
364///
365/// # Returns
366/// Ok(()) if valid, Err with descriptive message if invalid
367pub fn validate_price_precision(price: f64, max_sig_figs: usize) -> Result<(), String> {
368    // Convert f64 to string for validation
369    // We need to be careful about floating point precision artifacts
370
371    // Determine the order of magnitude
372    let abs_price = price.abs();
373
374    if abs_price == 0.0 {
375        return Ok(()); // Zero is always valid
376    }
377
378    // Calculate how many decimal places we need to see all possible significant figures
379    // We use max_sig_figs + 2 to account for potential precision artifacts
380    let log10 = abs_price.log10().floor();
381    let decimal_places = if log10 >= 0.0 {
382        // For numbers >= 1, we need (max_sig_figs - log10 - 1) decimal places
383        // But we add a few extra for safety
384        ((max_sig_figs as f64 - log10).max(0.0) + 3.0) as usize
385    } else {
386        // For numbers < 1, we need to account for leading zeros
387        // plus the significant figures
388        ((-log10) as usize + max_sig_figs + 3).min(15)
389    };
390
391    // Format with calculated precision
392    let price_str = format!("{:.prec$}", price, prec = decimal_places)
393        .trim_end_matches('0')
394        .trim_end_matches('.')
395        .to_string();
396
397    let sig_figs = count_significant_figures(&price_str)?;
398
399    if sig_figs > max_sig_figs {
400        Err(format!(
401            "Price {} has {} significant figures, maximum allowed is {}",
402            price, sig_figs, max_sig_figs
403        ))
404    } else {
405        Ok(())
406    }
407}
408
409/// Round a value to a specified number of significant figures.
410///
411/// # Arguments
412/// * `value` - The value to round
413/// * `sig_figs` - Number of significant figures to round to
414///
415/// # Returns
416/// The value rounded to the specified number of significant figures
417pub fn round_to_sig_figs(value: f64, sig_figs: usize) -> f64 {
418    if value == 0.0 || !value.is_finite() {
419        return value;
420    }
421
422    if sig_figs == 0 {
423        return 0.0;
424    }
425
426    // Get the sign and work with absolute value
427    let sign = value.signum();
428    let abs_value = value.abs();
429
430    // Calculate the order of magnitude
431    let magnitude = abs_value.log10().floor();
432
433    // Calculate the scaling factor to get sig_figs significant figures
434    // We want to round to sig_figs digits, so we need to scale by 10^(sig_figs - 1 - magnitude)
435    let scale = 10_f64.powf((sig_figs as f64 - 1.0) - magnitude);
436
437    // Round the scaled value
438    let scaled = abs_value * scale;
439    let rounded = scaled.round();
440
441    // Scale back and restore sign
442    sign * (rounded / scale)
443}
444
445/// Parsed symbol components.
446#[derive(Debug, Clone)]
447pub struct ParsedSymbol {
448    pub underlying: String,
449    pub expiry: u64,
450    pub strike: f64,
451    pub option_type: OptionType,
452}
453
454impl ParsedSymbol {
455    /// Parse a symbol string into its components.
456    ///
457    /// Supports both YYYYMMDD and DDMMMYY (Deribit) expiry formats.
458    pub fn from_symbol(symbol: &str) -> Result<Self, String> {
459        // Format: "BTC-20240131-100000-C" or "BTC-30AUG25-100000-C"
460        let parts: Vec<&str> = symbol.split('-').collect();
461        if parts.len() != 4 {
462            return Err(format!("Invalid symbol format: {}", symbol));
463        }
464
465        let underlying = parts[0].to_string();
466
467        // Parse expiry - handle both YYYYMMDD and DDMMMYY formats
468        let expiry = if parts[1].chars().all(|c| c.is_ascii_digit()) {
469            // YYYYMMDD format
470            parts[1]
471                .parse::<u64>()
472                .map_err(|_| format!("Invalid expiry: {}", parts[1]))?
473        } else {
474            // DDMMMYY format (e.g., "30AUG25")
475            parse_deribit_expiry(parts[1])
476                .ok_or_else(|| format!("Invalid expiry format: {}", parts[1]))?
477        };
478
479        let strike = parts[2]
480            .parse::<f64>()
481            .map_err(|_| format!("Invalid strike: {}", parts[2]))?;
482
483        let option_type = match parts[3] {
484            "C" | "c" => OptionType::Call,
485            "P" | "p" => OptionType::Put,
486            _ => return Err(format!("Invalid option type: {}", parts[3])),
487        };
488
489        Ok(ParsedSymbol {
490            underlying,
491            expiry,
492            strike,
493            option_type,
494        })
495    }
496}
497
498/// High-level instrument family parsed from a venue symbol.
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum InstrumentKind {
501    Option,
502    Perp,
503}
504
505pub fn classify_instrument_symbol(symbol: &str) -> Result<InstrumentKind, String> {
506    if is_perp_symbol(symbol) {
507        return Ok(InstrumentKind::Perp);
508    }
509
510    if ParsedSymbol::from_symbol(symbol).is_ok() {
511        return Ok(InstrumentKind::Option);
512    }
513
514    Err(format!(
515        "cannot classify symbol {} as option or perp",
516        symbol
517    ))
518}
519
520pub fn is_option_symbol(symbol: &str) -> bool {
521    ParsedSymbol::from_symbol(symbol).is_ok()
522}
523
524pub fn is_perp_symbol(symbol: &str) -> bool {
525    symbol.ends_with("-PERP")
526}
527
528/// Parse Deribit-style expiry (e.g., "30AUG25" -> 20250830).
529fn parse_deribit_expiry(expiry_str: &str) -> Option<u64> {
530    // Extract day, month, year from format like "30AUG25"
531    if expiry_str.len() < 5 {
532        return None;
533    }
534
535    // Find where the month starts (first non-digit character)
536    let day_end = expiry_str.chars().position(|c| !c.is_ascii_digit())?;
537    let day = expiry_str[..day_end].parse::<u32>().ok()?;
538
539    // Extract month (3 letters)
540    let month_str = &expiry_str[day_end..];
541    if month_str.len() < 5 {
542        // Need at least 3 chars for month + 2 for year
543        return None;
544    }
545    let month_abbr = &month_str[..3];
546    let month = match month_abbr {
547        "JAN" => 1,
548        "FEB" => 2,
549        "MAR" => 3,
550        "APR" => 4,
551        "MAY" => 5,
552        "JUN" => 6,
553        "JUL" => 7,
554        "AUG" => 8,
555        "SEP" => 9,
556        "OCT" => 10,
557        "NOV" => 11,
558        "DEC" => 12,
559        _ => return None,
560    };
561
562    // Extract year (2 digits)
563    let year_str = &month_str[3..];
564    let year = year_str.parse::<u32>().ok()?;
565    let full_year = 2000 + year;
566
567    // Convert to YYYYMMDD format
568    Some(full_year as u64 * 10000 + month as u64 * 100 + day as u64)
569}
570
571/// Get current timestamp in milliseconds.
572pub fn get_timestamp_millis() -> u64 {
573    std::time::SystemTime::now()
574        .duration_since(std::time::SystemTime::UNIX_EPOCH)
575        .unwrap()
576        .as_millis() as u64
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn test_to_human_readable() {
585        assert_eq!(to_human_readable("BTC-PERP", 1_000_000), 1.0);
586        assert_eq!(to_human_readable("BTC-PERP", 500_000), 0.5);
587        assert_eq!(to_human_readable("BTC-PERP", 2_500_000), 2.5);
588        assert_eq!(to_human_readable("ETH-PERP", 1_000_000), 1.0);
589    }
590
591    #[test]
592    fn test_to_contract_units() {
593        assert_eq!(to_contract_units("BTC-PERP", 1.0), 1_000_000);
594        assert_eq!(to_contract_units("BTC-PERP", 0.5), 500_000);
595        assert_eq!(to_contract_units("BTC-PERP", 2.5), 2_500_000);
596        assert_eq!(to_contract_units("ETH-PERP", 1.0), 1_000_000);
597    }
598
599    #[test]
600    fn test_round_trip_conversion() {
601        let original = 1.5;
602        let contract = to_contract_units("BTC-PERP", original);
603        let human = to_human_readable("BTC-PERP", contract);
604        assert!((human - original).abs() < 0.0001);
605    }
606
607    #[test]
608    fn test_count_sig_figs_basic() {
609        assert_eq!(count_significant_figures("12345").unwrap(), 5);
610        assert_eq!(count_significant_figures("1234").unwrap(), 4);
611        assert_eq!(count_significant_figures("123").unwrap(), 3);
612        assert_eq!(count_significant_figures("12").unwrap(), 2);
613        assert_eq!(count_significant_figures("1").unwrap(), 1);
614    }
615
616    #[test]
617    fn test_count_sig_figs_decimals() {
618        assert_eq!(count_significant_figures("1.2345").unwrap(), 5);
619        assert_eq!(count_significant_figures("12.345").unwrap(), 5);
620        assert_eq!(count_significant_figures("123.45").unwrap(), 5);
621        assert_eq!(count_significant_figures("1234.5").unwrap(), 5);
622    }
623
624    #[test]
625    fn test_count_sig_figs_leading_zeros() {
626        assert_eq!(count_significant_figures("0.12345").unwrap(), 5);
627        assert_eq!(count_significant_figures("0.012345").unwrap(), 5);
628        assert_eq!(count_significant_figures("0.0012345").unwrap(), 5);
629        assert_eq!(count_significant_figures("0.00012345").unwrap(), 5);
630        assert_eq!(count_significant_figures("0.000012345").unwrap(), 5);
631    }
632
633    #[test]
634    fn test_count_sig_figs_trailing_zeros_before_decimal() {
635        assert_eq!(count_significant_figures("12300").unwrap(), 3);
636        assert_eq!(count_significant_figures("55500").unwrap(), 3);
637        assert_eq!(count_significant_figures("10000").unwrap(), 1);
638        assert_eq!(count_significant_figures("100").unwrap(), 1);
639    }
640
641    #[test]
642    fn test_count_sig_figs_trailing_zeros_after_decimal() {
643        assert_eq!(count_significant_figures("123.00").unwrap(), 5);
644        assert_eq!(count_significant_figures("100.00").unwrap(), 5);
645        assert_eq!(count_significant_figures("1.0000").unwrap(), 5);
646        assert_eq!(count_significant_figures("10.000").unwrap(), 5);
647    }
648
649    #[test]
650    fn test_validate_price_precision_valid() {
651        assert!(validate_price_precision(12345.0, 5).is_ok());
652        assert!(validate_price_precision(1234.5, 5).is_ok());
653        assert!(validate_price_precision(123.45, 5).is_ok());
654        assert!(validate_price_precision(12.345, 5).is_ok());
655        assert!(validate_price_precision(1.2345, 5).is_ok());
656    }
657
658    #[test]
659    fn test_validate_price_precision_invalid() {
660        assert!(validate_price_precision(123456.0, 5).is_err());
661        assert!(validate_price_precision(12345.6, 5).is_err());
662        assert!(validate_price_precision(1234.56, 5).is_err());
663        assert!(validate_price_precision(123.456, 5).is_err());
664        assert!(validate_price_precision(12.3456, 5).is_err());
665        assert!(validate_price_precision(1.23456, 5).is_err());
666    }
667
668    #[test]
669    fn test_round_to_sig_figs_basic() {
670        assert!((round_to_sig_figs(12345.6, 5) - 12346.0).abs() < 0.1);
671        assert!((round_to_sig_figs(12345.4, 5) - 12345.0).abs() < 0.1);
672        assert!((round_to_sig_figs(123456.0, 5) - 123460.0).abs() < 1.0);
673        assert!((round_to_sig_figs(123456.0, 4) - 123500.0).abs() < 1.0);
674        assert!((round_to_sig_figs(123456.0, 3) - 123000.0).abs() < 1.0);
675    }
676
677    #[test]
678    fn test_expiry_date_to_timestamp_checked_known_value() {
679        assert_eq!(
680            expiry_date_to_timestamp_checked("BTC", 20250101).unwrap(),
681            1_735_718_400
682        );
683    }
684
685    #[test]
686    fn test_expiry_date_to_timestamp_checked_defaults_to_0800_utc() {
687        assert_eq!(
688            expiry_date_to_timestamp_checked("BTC", 20260605).unwrap(),
689            1_780_646_400
690        );
691    }
692
693    // Parity with the retired spcx-4pm-et-expiry build flag: a 20:00 UTC
694    // override must reproduce the exact timestamps AWS builds shipped with.
695    #[test]
696    fn test_2000_utc_override_matches_retired_spcx_flag_timestamps() {
697        let spcx_time = ExpiryTime::parse("20:00").unwrap();
698        assert_eq!(
699            expiry_date_to_timestamp_at_time_checked(20260605, spcx_time).unwrap(),
700            1_780_689_600
701        );
702    }
703
704    #[test]
705    fn test_expiry_date_to_timestamp_checked_rejects_invalid_date() {
706        let err = expiry_date_to_timestamp_checked("BTC", 20230229)
707            .unwrap_err()
708            .to_string();
709        assert!(err.contains("Invalid expiry date components"));
710    }
711
712    #[test]
713    fn test_expiry_date_to_timestamp_preserves_legacy_zero_fallback() {
714        assert_eq!(expiry_date_to_timestamp("BTC", 20230229), 0);
715    }
716
717    #[test]
718    fn test_strike_to_e8_exact_scaling() {
719        assert_eq!(strike_to_e8(dec!(100.5)).unwrap(), 10_050_000_000);
720        assert_eq!(strike_to_e8(dec!(0.05)).unwrap(), 5_000_000);
721    }
722
723    #[test]
724    fn test_strike_to_e8_rejects_precision_overflow_and_zero() {
725        assert!(strike_to_e8(dec!(100.000000001)).is_err());
726        assert!(strike_to_e8(Decimal::ZERO).is_err());
727    }
728
729    #[test]
730    fn test_parse_symbol_valid() {
731        let symbol = "BTC-20240131-100000-C";
732        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
733        assert_eq!(parsed.underlying, "BTC");
734        assert_eq!(parsed.expiry, 20240131);
735        assert_eq!(parsed.strike, 100000.0);
736        assert_eq!(parsed.option_type, OptionType::Call);
737    }
738
739    #[test]
740    fn test_parse_symbol_deribit_format() {
741        let symbol = "BTC-30AUG25-95000-C";
742        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
743        assert_eq!(parsed.underlying, "BTC");
744        assert_eq!(parsed.expiry, 20250830);
745        assert_eq!(parsed.strike, 95000.0);
746        assert_eq!(parsed.option_type, OptionType::Call);
747    }
748
749    #[test]
750    fn test_parse_symbol_put() {
751        let symbol = "ETH-20240228-3000-P";
752        let parsed = ParsedSymbol::from_symbol(symbol).unwrap();
753        assert_eq!(parsed.underlying, "ETH");
754        assert_eq!(parsed.expiry, 20240228);
755        assert_eq!(parsed.strike, 3000.0);
756        assert_eq!(parsed.option_type, OptionType::Put);
757    }
758
759    #[test]
760    fn test_classify_instrument_symbol_option_perp() {
761        assert_eq!(
762            classify_instrument_symbol("BTC-20240131-100000-C").unwrap(),
763            InstrumentKind::Option
764        );
765        assert_eq!(
766            classify_instrument_symbol("BTC-PERP").unwrap(),
767            InstrumentKind::Perp
768        );
769    }
770
771    #[test]
772    fn test_classify_instrument_symbol_rejects_unknown_symbol() {
773        let err = classify_instrument_symbol("BTC-BROKEN").unwrap_err();
774        assert!(err.contains("cannot classify symbol"));
775    }
776
777    // ========================================================================
778    // Contract Unit Conversion Tests
779    // ========================================================================
780
781    #[test]
782    fn test_contract_unit_round_trip_f64() {
783        // Test that converting human-readable -> contract units -> human-readable is lossless
784        let test_cases = [1.0, 0.5, 0.000001, 100.0, 1234.567890];
785        let symbol = "BTC-20240131-100000-C";
786
787        for human_readable in test_cases {
788            let contract_units = to_contract_units(symbol, human_readable);
789            let back = to_human_readable(symbol, contract_units);
790            // Allow small floating point error
791            assert!(
792                (back - human_readable).abs() < 0.0000001,
793                "Round-trip failed for {}: got {}",
794                human_readable,
795                back
796            );
797        }
798    }
799
800    #[test]
801    fn test_contract_unit_round_trip_decimal() {
802        // Test that converting human-readable -> contract units -> human-readable is lossless
803        // This is the critical test - Decimal should preserve precision exactly
804        let test_cases = [
805            dec!(1.0),
806            dec!(0.5),
807            dec!(0.000001),
808            dec!(100.0),
809            dec!(1234.567890),
810            dec!(60000.0), // The value that was failing in the margin test
811        ];
812        let symbol = "BTC-20240131-100000-C";
813
814        for human_readable in test_cases {
815            let contract_units = to_contract_units_decimal(symbol, human_readable);
816            let back = to_human_readable_decimal(symbol, contract_units);
817            assert_eq!(
818                back, human_readable,
819                "Decimal round-trip failed for {}: got {}",
820                human_readable, back
821            );
822        }
823    }
824
825    #[test]
826    fn test_contract_unit_multiplier_values() {
827        // Verify the multipliers are correct
828        assert_eq!(CONTRACT_UNIT_MULTIPLIER, 1_000_000.0);
829        assert_eq!(CONTRACT_UNIT_MULTIPLIER_DECIMAL, dec!(1000000));
830    }
831
832    #[test]
833    fn test_human_readable_to_contract_units() {
834        let symbol = "BTC-20240131-100000-C";
835
836        // 1.0 human-readable = 1,000,000 contract units
837        assert_eq!(to_contract_units(symbol, 1.0), 1_000_000);
838        assert_eq!(to_contract_units_decimal(symbol, dec!(1.0)), dec!(1000000));
839
840        // 0.00001 human-readable = 10 contract units
841        assert_eq!(to_contract_units(symbol, 0.00001), 10);
842        assert_eq!(to_contract_units_decimal(symbol, dec!(0.00001)), dec!(10));
843    }
844
845    #[test]
846    fn test_contract_units_to_human_readable() {
847        let symbol = "BTC-20240131-100000-C";
848
849        // 1,000,000 contract units = 1.0 human-readable
850        assert_eq!(to_human_readable(symbol, 1_000_000), 1.0);
851        assert_eq!(to_human_readable_decimal(symbol, dec!(1000000)), dec!(1.0));
852
853        // 10 contract units = 0.00001 human-readable
854        assert_eq!(to_human_readable(symbol, 10), 0.00001);
855        assert_eq!(to_human_readable_decimal(symbol, dec!(10)), dec!(0.00001));
856    }
857
858    #[test]
859    fn test_no_double_conversion_bug() {
860        // This tests the specific bug we fixed: if a value is already in human-readable
861        // units, converting it again should NOT be done. This test documents the
862        // correct values so we catch if someone accidentally double-converts.
863        let symbol = "BTC-20240131-100000-C";
864        let price = dec!(60000.0); // Price in USDC
865        let size_human_readable = dec!(1.0); // Already in human-readable units
866
867        // CORRECT: Premium = price * size (both in human-readable units)
868        let correct_premium = price * size_human_readable;
869        assert_eq!(correct_premium, dec!(60000.0));
870
871        // WRONG: If someone mistakenly converts human-readable size again
872        let wrong_size = to_human_readable_decimal(symbol, size_human_readable);
873        let wrong_premium = price * wrong_size;
874        assert_eq!(wrong_premium, dec!(0.06)); // This is the bug value we saw!
875
876        // The ratio between correct and wrong is exactly the multiplier
877        assert_eq!(
878            correct_premium / wrong_premium,
879            CONTRACT_UNIT_MULTIPLIER_DECIMAL
880        );
881    }
882}