Skip to main content

catalog_manager/
strikes.rs

1use crate::config::{ExtensionPolicyConfig, StrikeSelectionConfig};
2use anyhow::{Context, Result};
3use chrono::Utc;
4use std::collections::HashSet;
5
6#[derive(Debug, Clone)]
7pub struct StrikeSet {
8    pub strikes: Vec<f64>,
9    pub original_targets: Vec<f64>,
10    pub policy: StrikePolicyKind,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum StrikePolicyKind {
15    DeribitTable,
16    OccFallback,
17}
18
19#[derive(Debug, Clone)]
20pub struct ExtensionPlan {
21    pub needs_extension: bool,
22    pub reason: Option<String>,
23    pub new_strikes: Vec<f64>,
24}
25
26#[derive(Debug, Clone, Copy)]
27pub struct ExtensionRequest<'a> {
28    pub underlying: &'a str,
29    pub current_spot: f64,
30    pub ref_price_at_listing: f64,
31    pub expiry_timestamp: i64,
32    pub existing_strikes: &'a [f64],
33    pub reference_timestamp_secs: i64,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum ExpiryTenor {
38    Daily,
39    Weekly,
40    Monthly,
41    Quarterly,
42}
43
44#[derive(Debug, Clone, Copy)]
45struct IntervalPolicy {
46    atm: f64,
47    outer: f64,
48    wings: Option<f64>,
49}
50
51pub fn generate_strike_set(
52    underlying: &str,
53    spot_price: f64,
54    expiry_timestamp: i64,
55    config: &StrikeSelectionConfig,
56) -> Result<StrikeSet> {
57    generate_strike_set_at_time(
58        underlying,
59        spot_price,
60        expiry_timestamp,
61        config,
62        Utc::now().timestamp(),
63    )
64}
65
66pub fn generate_strike_set_at_time(
67    underlying: &str,
68    spot_price: f64,
69    expiry_timestamp: i64,
70    config: &StrikeSelectionConfig,
71    reference_timestamp_secs: i64,
72) -> Result<StrikeSet> {
73    validate_spot(spot_price, underlying)?;
74
75    let normalized = underlying.to_ascii_uppercase();
76    let (strikes, policy) = if uses_deribit_table(&normalized, config) {
77        let tenor = classify_tenor_at(expiry_timestamp, reference_timestamp_secs);
78        (
79            generate_deribit_table_strikes(&normalized, spot_price, tenor, config)
80                .with_context(|| format!("Failed to generate Deribit strikes for {normalized}"))?,
81            StrikePolicyKind::DeribitTable,
82        )
83    } else {
84        (
85            generate_occ_fallback_strikes(spot_price, config.occ_fallback_side_count)?,
86            StrikePolicyKind::OccFallback,
87        )
88    };
89
90    if strikes.is_empty() {
91        anyhow::bail!(
92            "No strikes generated for {} at spot {} and expiry {}",
93            underlying,
94            spot_price,
95            expiry_timestamp
96        );
97    }
98
99    Ok(StrikeSet {
100        original_targets: strikes.clone(),
101        strikes,
102        policy,
103    })
104}
105
106pub fn plan_extension(
107    underlying: &str,
108    current_spot: f64,
109    ref_price_at_listing: f64,
110    expiry_timestamp: i64,
111    existing_strikes: &[f64],
112    strike_config: &StrikeSelectionConfig,
113    extension_config: &ExtensionPolicyConfig,
114) -> Result<ExtensionPlan> {
115    plan_extension_at_time(
116        ExtensionRequest {
117            underlying,
118            current_spot,
119            ref_price_at_listing,
120            expiry_timestamp,
121            existing_strikes,
122            reference_timestamp_secs: Utc::now().timestamp(),
123        },
124        strike_config,
125        extension_config,
126    )
127}
128
129pub fn plan_extension_at_time(
130    request: ExtensionRequest<'_>,
131    strike_config: &StrikeSelectionConfig,
132    extension_config: &ExtensionPolicyConfig,
133) -> Result<ExtensionPlan> {
134    if !extension_config.enabled {
135        return Ok(ExtensionPlan {
136            needs_extension: false,
137            reason: None,
138            new_strikes: vec![],
139        });
140    }
141
142    validate_spot(request.current_spot, request.underlying)?;
143    validate_spot(request.ref_price_at_listing, request.underlying)?;
144
145    let spot_move_pct =
146        (request.current_spot - request.ref_price_at_listing).abs() / request.ref_price_at_listing;
147    if spot_move_pct < extension_config.min_spot_move_pct {
148        return Ok(ExtensionPlan {
149            needs_extension: false,
150            reason: Some(format!(
151                "Spot move {:.2}% < threshold {:.2}%",
152                spot_move_pct * 100.0,
153                extension_config.min_spot_move_pct * 100.0
154            )),
155            new_strikes: vec![],
156        });
157    }
158
159    if request.existing_strikes.len() >= extension_config.max_total_strikes_per_expiry {
160        return Ok(ExtensionPlan {
161            needs_extension: false,
162            reason: Some(format!(
163                "Already at max strikes: {}",
164                extension_config.max_total_strikes_per_expiry
165            )),
166            new_strikes: vec![],
167        });
168    }
169
170    let strikes_below = request
171        .existing_strikes
172        .iter()
173        .copied()
174        .filter(|strike| *strike < request.current_spot)
175        .count();
176    let strikes_above = request
177        .existing_strikes
178        .iter()
179        .copied()
180        .filter(|strike| *strike > request.current_spot)
181        .count();
182
183    let mut reasons = Vec::new();
184    if strikes_below < extension_config.ensure_min_strikes_per_side {
185        reasons.push(format!(
186            "Only {} strikes below spot (need {})",
187            strikes_below, extension_config.ensure_min_strikes_per_side
188        ));
189    }
190    if strikes_above < extension_config.ensure_min_strikes_per_side {
191        reasons.push(format!(
192            "Only {} strikes above spot (need {})",
193            strikes_above, extension_config.ensure_min_strikes_per_side
194        ));
195    }
196
197    if let Some(nearest) = request.existing_strikes.iter().min_by(|a, b| {
198        let da = (*a - request.current_spot).abs();
199        let db = (*b - request.current_spot).abs();
200        da.total_cmp(&db)
201    }) {
202        let distance_pct = (nearest - request.current_spot).abs() / request.current_spot;
203        if distance_pct > extension_config.ensure_atm_within_pct {
204            reasons.push(format!(
205                "Nearest strike {:.2}% from spot (max {:.2}%)",
206                distance_pct * 100.0,
207                extension_config.ensure_atm_within_pct * 100.0
208            ));
209        }
210    }
211
212    if reasons.is_empty() {
213        return Ok(ExtensionPlan {
214            needs_extension: false,
215            reason: None,
216            new_strikes: vec![],
217        });
218    }
219
220    let existing_keys: HashSet<u64> = request
221        .existing_strikes
222        .iter()
223        .map(|strike| strike.to_bits())
224        .collect();
225    let mut new_strikes: Vec<f64> = generate_strike_set_at_time(
226        request.underlying,
227        request.current_spot,
228        request.expiry_timestamp,
229        strike_config,
230        request.reference_timestamp_secs,
231    )?
232    .strikes
233    .into_iter()
234    .filter(|strike| !existing_keys.contains(&strike.to_bits()))
235    .collect();
236
237    let remaining_capacity = extension_config
238        .max_total_strikes_per_expiry
239        .saturating_sub(request.existing_strikes.len());
240    new_strikes.truncate(remaining_capacity);
241
242    Ok(ExtensionPlan {
243        needs_extension: !new_strikes.is_empty(),
244        reason: Some(reasons.join("; ")),
245        new_strikes,
246    })
247}
248
249fn generate_deribit_table_strikes(
250    underlying: &str,
251    spot_price: f64,
252    tenor: ExpiryTenor,
253    config: &StrikeSelectionConfig,
254) -> Result<Vec<f64>> {
255    let policy = deribit_interval_policy(underlying, tenor)
256        .with_context(|| format!("No Deribit strike interval table for {underlying}"))?;
257    validate_interval(policy.atm, "Deribit ATM interval")?;
258    validate_interval(policy.outer, "Deribit outer interval")?;
259    if let Some(wings) = policy.wings {
260        validate_interval(wings, "Deribit wings interval")?;
261    }
262
263    let atm = round_to_interval(spot_price, policy.atm);
264    if atm <= 0.0 {
265        anyhow::bail!(
266            "Rounded ATM strike {} is not positive for spot {}",
267            atm,
268            spot_price
269        );
270    }
271
272    let mut strikes = vec![atm];
273    add_region(
274        &mut strikes,
275        atm,
276        policy.atm,
277        config.deribit_region_steps.atm,
278    );
279    add_region(
280        &mut strikes,
281        atm,
282        -policy.atm,
283        config.deribit_region_steps.atm,
284    );
285
286    let outer_high_base = atm + policy.atm * config.deribit_region_steps.atm as f64;
287    let outer_low_base = atm - policy.atm * config.deribit_region_steps.atm as f64;
288    add_region(
289        &mut strikes,
290        outer_high_base,
291        policy.outer,
292        config.deribit_region_steps.outer,
293    );
294    add_region(
295        &mut strikes,
296        outer_low_base,
297        -policy.outer,
298        config.deribit_region_steps.outer,
299    );
300
301    if let Some(wings) = policy.wings {
302        let wings_high_base =
303            outer_high_base + policy.outer * config.deribit_region_steps.outer as f64;
304        let wings_low_base =
305            outer_low_base - policy.outer * config.deribit_region_steps.outer as f64;
306        add_region(
307            &mut strikes,
308            wings_high_base,
309            wings,
310            config.deribit_region_steps.wings,
311        );
312        add_region(
313            &mut strikes,
314            wings_low_base,
315            -wings,
316            config.deribit_region_steps.wings,
317        );
318    }
319
320    normalize_strikes(strikes)
321}
322
323fn generate_occ_fallback_strikes(spot_price: f64, side_count: usize) -> Result<Vec<f64>> {
324    if side_count == 0 {
325        anyhow::bail!("OCC fallback side count must be > 0");
326    }
327
328    let atm_interval = occ_interval_for_price(spot_price)?;
329    let atm = round_to_interval(spot_price, atm_interval);
330    if atm <= 0.0 {
331        anyhow::bail!(
332            "Rounded OCC ATM strike {} is not positive for spot {}",
333            atm,
334            spot_price
335        );
336    }
337
338    let mut strikes = vec![atm];
339    let mut up = atm;
340    for _ in 0..side_count {
341        up += occ_interval_for_price(up)?;
342        strikes.push(up);
343    }
344
345    let mut down = atm;
346    for _ in 0..side_count {
347        let interval = occ_interval_for_price(down)?;
348        down -= interval;
349        if down > 0.0 {
350            strikes.push(round_to_precision(down));
351        }
352    }
353
354    normalize_strikes(strikes)
355}
356
357fn deribit_interval_policy(underlying: &str, tenor: ExpiryTenor) -> Option<IntervalPolicy> {
358    // Source: Deribit Contract Introduction Policy, Dynamic Options Strike Intervals.
359    match (underlying, tenor) {
360        ("BTC", ExpiryTenor::Daily) => Some(policy(500.0, 500.0, Some(1_000.0))),
361        ("BTC", ExpiryTenor::Weekly) | ("BTC", ExpiryTenor::Monthly) => {
362            Some(policy(1_000.0, 2_000.0, Some(5_000.0)))
363        }
364        ("BTC", ExpiryTenor::Quarterly) => Some(policy(2_000.0, 5_000.0, Some(10_000.0))),
365        ("ETH", ExpiryTenor::Daily) => Some(policy(25.0, 50.0, Some(100.0))),
366        ("ETH", ExpiryTenor::Weekly) | ("ETH", ExpiryTenor::Monthly) => {
367            Some(policy(50.0, 100.0, Some(200.0)))
368        }
369        ("ETH", ExpiryTenor::Quarterly) => Some(policy(100.0, 200.0, Some(500.0))),
370        ("AVAX", ExpiryTenor::Daily) => Some(policy(0.1, 0.2, None)),
371        ("AVAX", ExpiryTenor::Weekly) | ("AVAX", ExpiryTenor::Monthly) => {
372            Some(policy(0.2, 0.5, Some(1.0)))
373        }
374        ("AVAX", ExpiryTenor::Quarterly) => Some(policy(1.0, 2.0, Some(5.0))),
375        ("SOL", ExpiryTenor::Daily) => Some(policy(1.0, 2.0, None)),
376        ("SOL", ExpiryTenor::Weekly) => Some(policy(4.0, 10.0, Some(20.0))),
377        ("SOL", ExpiryTenor::Monthly) => Some(policy(5.0, 10.0, Some(20.0))),
378        ("SOL", ExpiryTenor::Quarterly) => Some(policy(5.0, 10.0, Some(20.0))),
379        // Deribit's published TRX daily table lists outer intervals tighter than ATM.
380        ("TRX", ExpiryTenor::Daily) => Some(policy(0.025, 0.005, None)),
381        ("TRX", ExpiryTenor::Weekly)
382        | ("TRX", ExpiryTenor::Monthly)
383        | ("TRX", ExpiryTenor::Quarterly) => Some(policy(0.005, 0.01, Some(0.02))),
384        ("XRP", ExpiryTenor::Daily) | ("XRP", ExpiryTenor::Weekly) => {
385            Some(policy(0.025, 0.05, Some(0.1)))
386        }
387        ("XRP", ExpiryTenor::Monthly) => Some(policy(0.05, 0.1, Some(0.2))),
388        ("XRP", ExpiryTenor::Quarterly) => Some(policy(0.1, 0.2, Some(0.5))),
389        _ => None,
390    }
391}
392
393fn uses_deribit_table(underlying: &str, config: &StrikeSelectionConfig) -> bool {
394    config
395        .deribit_table_assets
396        .iter()
397        .any(|asset| asset.eq_ignore_ascii_case(underlying))
398}
399
400fn policy(atm: f64, outer: f64, wings: Option<f64>) -> IntervalPolicy {
401    IntervalPolicy { atm, outer, wings }
402}
403
404fn classify_tenor_at(expiry_timestamp: i64, reference_timestamp_secs: i64) -> ExpiryTenor {
405    let seconds = expiry_timestamp.saturating_sub(reference_timestamp_secs);
406    let days = (seconds as f64 / 86_400.0).ceil();
407    if days <= 4.0 {
408        ExpiryTenor::Daily
409    } else if days <= 14.0 {
410        ExpiryTenor::Weekly
411    } else if days <= 65.0 {
412        ExpiryTenor::Monthly
413    } else {
414        ExpiryTenor::Quarterly
415    }
416}
417
418fn occ_interval_for_price(price: f64) -> Result<f64> {
419    // Source: OCC Equity Options Product Specifications, general strike price intervals.
420    validate_spot(price, "OCC strike")?;
421    if price < 25.0 {
422        Ok(2.5)
423    } else if price <= 200.0 {
424        Ok(5.0)
425    } else {
426        Ok(10.0)
427    }
428}
429
430fn add_region(strikes: &mut Vec<f64>, base: f64, step: f64, count: usize) {
431    for i in 1..=count {
432        let strike = base + step * i as f64;
433        if strike > 0.0 {
434            strikes.push(round_to_precision(strike));
435        }
436    }
437}
438
439fn normalize_strikes(mut strikes: Vec<f64>) -> Result<Vec<f64>> {
440    strikes.retain(|strike| strike.is_finite() && *strike > 0.0);
441    strikes.iter_mut().for_each(|strike| {
442        *strike = round_to_precision(*strike);
443    });
444    strikes.sort_by(|a, b| a.total_cmp(b));
445    strikes.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
446    if strikes.is_empty() {
447        anyhow::bail!("Generated strike list is empty");
448    }
449    Ok(strikes)
450}
451
452fn validate_spot(spot_price: f64, underlying: &str) -> Result<()> {
453    if !spot_price.is_finite() || spot_price <= 0.0 {
454        anyhow::bail!(
455            "Invalid spot price {} for {} - cannot generate strikes",
456            spot_price,
457            underlying
458        );
459    }
460    Ok(())
461}
462
463fn validate_interval(interval: f64, label: &str) -> Result<()> {
464    if !interval.is_finite() || interval <= 0.0 {
465        anyhow::bail!("{} must be positive, got {}", label, interval);
466    }
467    Ok(())
468}
469
470fn round_to_interval(value: f64, interval: f64) -> f64 {
471    round_to_precision((value / interval).round() * interval)
472}
473
474fn round_to_precision(value: f64) -> f64 {
475    (value * 100_000_000.0).round() / 100_000_000.0
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    fn config() -> StrikeSelectionConfig {
483        StrikeSelectionConfig {
484            deribit_table_assets: vec![
485                "BTC".to_string(),
486                "ETH".to_string(),
487                "SOL".to_string(),
488                "AVAX".to_string(),
489                "XRP".to_string(),
490                "TRX".to_string(),
491            ],
492            deribit_region_steps: crate::config::DeribitRegionStepsConfig {
493                atm: 2,
494                outer: 2,
495                wings: 1,
496            },
497            occ_fallback_side_count: 3,
498        }
499    }
500
501    fn expiry_in_days(days: i64) -> i64 {
502        Utc::now().timestamp() + days * 86_400
503    }
504
505    #[test]
506    fn deribit_btc_uses_daily_intervals() {
507        let set = generate_strike_set("BTC", 100_000.0, expiry_in_days(2), &config()).unwrap();
508        assert_eq!(
509            set.strikes,
510            vec![
511                97_000.0, 98_000.0, 98_500.0, 99_000.0, 99_500.0, 100_000.0, 100_500.0, 101_000.0,
512                101_500.0, 102_000.0, 103_000.0
513            ]
514        );
515        assert_eq!(set.policy, StrikePolicyKind::DeribitTable);
516    }
517
518    #[test]
519    fn deribit_tenor_can_be_anchored_to_listing_time() {
520        let now = Utc::now().timestamp();
521        let expiry = now + 2 * 86_400;
522        let listed_at = now - 30 * 86_400;
523
524        let set =
525            generate_strike_set_at_time("BTC", 100_000.0, expiry, &config(), listed_at).unwrap();
526        assert_eq!(
527            set.strikes,
528            vec![
529                89_000.0, 94_000.0, 96_000.0, 98_000.0, 99_000.0, 100_000.0, 101_000.0, 102_000.0,
530                104_000.0, 106_000.0, 111_000.0,
531            ]
532        );
533        assert_eq!(set.policy, StrikePolicyKind::DeribitTable);
534    }
535
536    #[test]
537    fn deribit_eth_uses_quarterly_intervals() {
538        let set = generate_strike_set("ETH", 3_550.0, expiry_in_days(120), &config()).unwrap();
539        assert!(set.strikes.contains(&3_600.0));
540        assert!(set.strikes.contains(&4_700.0));
541        assert_eq!(set.policy, StrikePolicyKind::DeribitTable);
542    }
543
544    #[test]
545    fn deribit_linear_assets_have_table_coverage() {
546        for asset in ["SOL", "AVAX", "XRP", "TRX"] {
547            for days in [2, 10, 40, 120] {
548                let set =
549                    generate_strike_set(asset, 100.0, expiry_in_days(days), &config()).unwrap();
550                assert!(!set.strikes.is_empty(), "{asset} {days}");
551                assert_eq!(set.policy, StrikePolicyKind::DeribitTable);
552            }
553        }
554    }
555
556    #[test]
557    fn occ_fallback_uses_price_bands() {
558        let low = generate_strike_set("HYPE", 20.0, expiry_in_days(30), &config()).unwrap();
559        assert_eq!(low.strikes, vec![12.5, 15.0, 17.5, 20.0, 22.5, 25.0, 30.0]);
560
561        let mid = generate_strike_set("USOIL", 100.0, expiry_in_days(30), &config()).unwrap();
562        assert_eq!(
563            mid.strikes,
564            vec![85.0, 90.0, 95.0, 100.0, 105.0, 110.0, 115.0]
565        );
566
567        let high = generate_strike_set("US500", 5_000.0, expiry_in_days(30), &config()).unwrap();
568        assert_eq!(
569            high.strikes,
570            vec![4_970.0, 4_980.0, 4_990.0, 5_000.0, 5_010.0, 5_020.0, 5_030.0]
571        );
572        assert_eq!(high.policy, StrikePolicyKind::OccFallback);
573    }
574
575    #[test]
576    fn invalid_spot_fails() {
577        assert!(generate_strike_set("BTC", 0.0, expiry_in_days(2), &config()).is_err());
578    }
579
580    #[test]
581    fn extension_adds_only_missing_strikes() {
582        let extension = ExtensionPolicyConfig {
583            enabled: true,
584            ensure_min_strikes_per_side: 2,
585            ensure_atm_within_pct: 0.02,
586            cooldown_secs: 3600,
587            max_total_strikes_per_expiry: 30,
588            min_spot_move_pct: 0.05,
589        };
590        let plan = plan_extension(
591            "BTC",
592            110_000.0,
593            100_000.0,
594            expiry_in_days(2),
595            &[99_000.0, 100_000.0, 101_000.0],
596            &config(),
597            &extension,
598        )
599        .unwrap();
600        assert!(plan.needs_extension);
601        assert!(plan.new_strikes.iter().any(|strike| *strike > 110_000.0));
602    }
603
604    #[test]
605    fn extension_uses_anchored_listing_tenor() {
606        let extension = ExtensionPolicyConfig {
607            enabled: true,
608            ensure_min_strikes_per_side: 2,
609            ensure_atm_within_pct: 0.02,
610            cooldown_secs: 3600,
611            max_total_strikes_per_expiry: 30,
612            min_spot_move_pct: 0.05,
613        };
614        let now = Utc::now().timestamp();
615        let expiry = now + 2 * 86_400;
616        let existing = generate_strike_set("BTC", 100_000.0, expiry, &config())
617            .unwrap()
618            .strikes;
619
620        let plan = plan_extension_at_time(
621            ExtensionRequest {
622                underlying: "BTC",
623                current_spot: 110_000.0,
624                ref_price_at_listing: 100_000.0,
625                expiry_timestamp: expiry,
626                existing_strikes: &existing,
627                reference_timestamp_secs: now - 30 * 86_400,
628            },
629            &config(),
630            &extension,
631        )
632        .unwrap();
633
634        assert!(plan.needs_extension);
635        assert!(plan.new_strikes.contains(&121_000.0));
636    }
637
638    #[test]
639    fn testnet_catalog_generates_expected_strikes() {
640        let catalog = crate::config::with_secret_placeholder_mode(
641            crate::config::SecretPlaceholderMode::AllowUnresolved,
642            || {
643                crate::config::parse_catalog_config(include_str!(concat!(
644                    env!("CARGO_MANIFEST_DIR"),
645                    "/../../market_catalog_testnet.yaml"
646                )))
647            },
648        )
649        .unwrap();
650
651        let btc = generate_strike_set(
652            "BTC",
653            100_000.0,
654            expiry_in_days(2),
655            &catalog.strike_selection,
656        )
657        .unwrap();
658        assert_eq!(btc.policy, StrikePolicyKind::DeribitTable);
659        assert_eq!(
660            btc.strikes,
661            vec![
662                93_500.0, 94_500.0, 95_500.0, 96_500.0, 97_000.0, 97_500.0, 98_000.0, 98_500.0,
663                99_000.0, 99_500.0, 100_000.0, 100_500.0, 101_000.0, 101_500.0, 102_000.0,
664                102_500.0, 103_000.0, 103_500.0, 104_500.0, 105_500.0, 106_500.0,
665            ]
666        );
667
668        let hype = generate_strike_set("HYPE", 20.0, expiry_in_days(30), &catalog.strike_selection)
669            .unwrap();
670        assert_eq!(hype.policy, StrikePolicyKind::OccFallback);
671        assert_eq!(
672            hype.strikes,
673            vec![
674                2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 17.5, 20.0, 22.5, 25.0, 30.0, 35.0, 40.0, 45.0,
675                50.0, 55.0,
676            ]
677        );
678    }
679}