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 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 ("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 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}