1use std::fmt::Debug;
19
20use anyhow::Context;
21use nautilus_common::{actor::DataActor, timer::TimeEvent};
22use nautilus_core::{DurationNanos, params::Params};
23use nautilus_model::{
24 data::{QuoteTick, black_scholes::compute_greeks, option_chain::OptionGreeks},
25 enums::{OptionKind, OrderSide, TimeInForce},
26 events::{OrderCanceled, OrderDenied, OrderExpired, OrderFilled, OrderRejected},
27 identifiers::{ClientId, InstrumentId},
28 instruments::Instrument,
29 orders::Order,
30 types::{Price, Quantity},
31};
32use rust_decimal::{Decimal, prelude::ToPrimitive};
33use serde_json::json;
34use ustr::Ustr;
35
36use super::config::DeltaNeutralVolConfig;
37use crate::{
38 nautilus_strategy,
39 strategy::{Strategy, StrategyCore},
40};
41
42const REHEDGE_TIMER: &str = "delta_rehedge";
43
44pub struct DeltaNeutralVol {
51 pub(super) core: StrategyCore,
52 pub(super) config: DeltaNeutralVolConfig,
53 pub(super) call_instrument_id: Option<InstrumentId>,
54 pub(super) put_instrument_id: Option<InstrumentId>,
55 pub(super) subscribed_greeks: Vec<InstrumentId>,
56 pub(super) call_delta: f64,
57 pub(super) put_delta: f64,
58 pub(super) call_mark_iv: Option<f64>,
59 pub(super) put_mark_iv: Option<f64>,
60 pub(super) call_quote: Option<QuoteTick>,
61 pub(super) put_quote: Option<QuoteTick>,
62 pub(super) call_greeks: Option<OptionGreeks>,
63 pub(super) put_greeks: Option<OptionGreeks>,
64 pub(super) call_delta_ready: bool,
65 pub(super) put_delta_ready: bool,
66 pub(super) call_position: f64,
67 pub(super) put_position: f64,
68 pub(super) hedge_position: f64,
69 pub(super) hedge_pending: bool,
70 pub(super) entry_attempted: bool,
71}
72
73impl DeltaNeutralVol {
74 #[must_use]
76 pub fn new(config: DeltaNeutralVolConfig) -> Self {
77 Self {
78 core: StrategyCore::new(config.base.clone()),
79 call_instrument_id: None,
80 put_instrument_id: None,
81 subscribed_greeks: Vec::new(),
82 call_delta: 0.0,
83 put_delta: 0.0,
84 call_mark_iv: None,
85 put_mark_iv: None,
86 call_quote: None,
87 put_quote: None,
88 call_greeks: None,
89 put_greeks: None,
90 call_delta_ready: false,
91 put_delta_ready: false,
92 call_position: 0.0,
93 put_position: 0.0,
94 hedge_position: 0.0,
95 hedge_pending: false,
96 entry_attempted: false,
97 config,
98 }
99 }
100
101 #[must_use]
103 pub fn portfolio_delta(&self) -> f64 {
104 self.call_delta * self.call_position
105 + self.put_delta * self.put_position
106 + self.hedge_position
107 }
108
109 #[must_use]
111 pub fn greeks_initialized(&self) -> bool {
112 self.call_instrument_id.is_some()
113 && self.put_instrument_id.is_some()
114 && self.call_delta_ready
115 && self.put_delta_ready
116 }
117
118 #[must_use]
120 pub fn should_rehedge(&self) -> bool {
121 self.greeks_initialized()
122 && self.portfolio_delta().abs() > self.config.rehedge_delta_threshold
123 }
124
125 #[must_use]
127 pub fn should_enter_strangle(&self) -> bool {
128 self.config.enter_strangle
129 && self.greeks_initialized()
130 && self.entry_price_data_ready()
131 && self.call_position == 0.0
132 && self.put_position == 0.0
133 && !self.entry_attempted
134 && !self.has_working_entry_orders()
135 }
136
137 #[must_use]
139 pub fn entry_price_data_ready(&self) -> bool {
140 if self.config.entry_premium_offset_ticks.is_some() {
141 let Some(call_id) = self.call_instrument_id else {
142 return false;
143 };
144 let Some(put_id) = self.put_instrument_id else {
145 return false;
146 };
147
148 return self.premium_entry_data_ready(call_id, self.call_quote, self.call_greeks)
149 && self.premium_entry_data_ready(put_id, self.put_quote, self.put_greeks);
150 }
151
152 self.call_mark_iv.is_some() && self.put_mark_iv.is_some()
153 }
154
155 fn premium_entry_data_ready(
156 &self,
157 instrument_id: InstrumentId,
158 quote: Option<QuoteTick>,
159 greeks: Option<OptionGreeks>,
160 ) -> bool {
161 if quote.is_some_and(|q| q.ask_price.as_decimal() > Decimal::ZERO) {
162 return true;
163 }
164
165 let Some(greeks) = greeks else {
166 return false;
167 };
168
169 self.premium_from_greeks_ready(instrument_id, greeks)
170 }
171
172 fn premium_from_greeks_ready(&self, instrument_id: InstrumentId, greeks: OptionGreeks) -> bool {
173 let Some(underlying_price) = greeks.underlying_price else {
174 return false;
175 };
176 let Some(vol) = greeks.ask_iv.filter(|v| *v > 0.0).or(greeks.mark_iv) else {
177 return false;
178 };
179 let has_option_terms = {
180 let cache = self.cache();
181 let Some(instrument) = cache.instrument(&instrument_id) else {
182 return false;
183 };
184
185 instrument.strike_price().is_some()
186 && instrument.expiration_ns().is_some()
187 && instrument.option_kind().is_some()
188 };
189
190 underlying_price > 0.0 && vol > 0.0 && has_option_terms
191 }
192
193 #[must_use]
195 pub fn has_working_entry_orders(&self) -> bool {
196 let cache = self.cache();
197
198 for id in [self.call_instrument_id, self.put_instrument_id]
199 .into_iter()
200 .flatten()
201 {
202 let open = cache.orders_open(None, Some(&id), None, None, None);
203 let inflight = cache.orders_inflight(None, Some(&id), None, None, None);
204
205 if !open.is_empty() || !inflight.is_empty() {
206 return true;
207 }
208 }
209 false
210 }
211
212 fn enter_strangle(&mut self) -> anyhow::Result<()> {
213 if !self.should_enter_strangle() {
214 return Ok(());
215 }
216
217 let call_id = self.call_instrument_id.unwrap();
218 let put_id = self.put_instrument_id.unwrap();
219 let contracts = self.config.contracts;
220 let tif = self.config.entry_time_in_force;
221 let client_id = self.config.client_id;
222
223 if let Some(offset_ticks) = self.config.entry_premium_offset_ticks {
224 let call_price =
225 self.entry_premium_price(call_id, self.call_quote, self.call_greeks)?;
226 let put_price = self.entry_premium_price(put_id, self.put_quote, self.put_greeks)?;
227
228 log::info!(
229 "Entering strangle: SELL {contracts} x {call_id} @ premium={call_price} \
230 + SELL {contracts} x {put_id} @ premium={put_price} \
231 (ask_offset_ticks={offset_ticks})",
232 );
233
234 self.submit_entry_order(call_id, contracts, call_price, tif, client_id, None)?;
235 self.submit_entry_order(put_id, contracts, put_price, tif, client_id, None)?;
236 } else {
237 let call_iv = self.call_mark_iv.unwrap();
238 let put_iv = self.put_mark_iv.unwrap();
239 let offset = self.config.entry_iv_offset;
240 let call_entry_iv = call_iv - offset;
241 let put_entry_iv = put_iv - offset;
242
243 log::info!(
244 "Entering strangle: SELL {contracts} x {call_id} @ iv={call_entry_iv:.4} \
245 + SELL {contracts} x {put_id} @ iv={put_entry_iv:.4} (offset={offset})",
246 );
247
248 let call_params = Params::new(
249 [(
250 self.config.iv_param_key.clone(),
251 json!(call_entry_iv.to_string()),
252 )]
253 .into(),
254 );
255
256 self.submit_entry_order(
257 call_id,
258 contracts,
259 Price::new(call_entry_iv, 4),
260 tif,
261 client_id,
262 Some(call_params),
263 )?;
264
265 let put_params = Params::new(
266 [(
267 self.config.iv_param_key.clone(),
268 json!(put_entry_iv.to_string()),
269 )]
270 .into(),
271 );
272
273 self.submit_entry_order(
274 put_id,
275 contracts,
276 Price::new(put_entry_iv, 4),
277 tif,
278 client_id,
279 Some(put_params),
280 )?;
281 }
282
283 self.entry_attempted = true;
284
285 Ok(())
286 }
287
288 fn entry_premium_price(
289 &self,
290 instrument_id: InstrumentId,
291 quote: Option<QuoteTick>,
292 greeks: Option<OptionGreeks>,
293 ) -> anyhow::Result<Price> {
294 if let Some(quote) = quote
295 && quote.ask_price.as_decimal() > Decimal::ZERO
296 {
297 return self.offset_entry_price(instrument_id, quote.ask_price.as_f64());
298 }
299
300 let greeks = greeks.with_context(|| {
301 format!("missing quote and Greeks for premium entry on {instrument_id}")
302 })?;
303 let base_price = self.entry_premium_from_greeks(instrument_id, greeks)?;
304
305 self.offset_entry_price(instrument_id, base_price)
306 }
307
308 fn offset_entry_price(
309 &self,
310 instrument_id: InstrumentId,
311 base_price: f64,
312 ) -> anyhow::Result<Price> {
313 let offset_ticks = self
314 .config
315 .entry_premium_offset_ticks
316 .context("missing premium entry offset")?;
317
318 let cache = self.cache();
319 let instrument = cache.try_instrument(&instrument_id)?;
320
321 instrument
322 .next_ask_price(base_price, offset_ticks)
323 .with_context(|| {
324 format!(
325 "failed to offset premium for {instrument_id}: price={base_price}, ticks={offset_ticks}"
326 )
327 })
328 }
329
330 fn entry_premium_from_greeks(
331 &self,
332 instrument_id: InstrumentId,
333 greeks: OptionGreeks,
334 ) -> anyhow::Result<f64> {
335 let (strike, expiration_ns, is_call) = {
336 let cache = self.cache();
337 let instrument = cache.try_instrument(&instrument_id)?;
338 let strike = instrument
339 .strike_price()
340 .with_context(|| format!("missing strike for {instrument_id}"))?
341 .as_f64();
342 let expiration_ns = instrument
343 .expiration_ns()
344 .with_context(|| format!("missing expiry for {instrument_id}"))?;
345 let option_kind = instrument
346 .option_kind()
347 .with_context(|| format!("missing option kind for {instrument_id}"))?;
348 let is_call = matches!(option_kind, OptionKind::Call);
349
350 (strike, expiration_ns, is_call)
351 };
352 let now_ns = self.clock().timestamp_ns();
353
354 if expiration_ns <= now_ns {
355 anyhow::bail!("Cannot price premium entry for expired instrument {instrument_id}");
356 }
357
358 let underlying_price = greeks
359 .underlying_price
360 .with_context(|| format!("missing underlying price for {instrument_id}"))?;
361 let (vol_source, vol) = greeks
362 .ask_iv
363 .filter(|v| *v > 0.0)
364 .map(|v| ("ask_iv", v))
365 .or_else(|| greeks.mark_iv.filter(|v| *v > 0.0).map(|v| ("mark_iv", v)))
366 .with_context(|| format!("missing positive IV for {instrument_id}"))?;
367 let years_to_expiry =
368 (expiration_ns - now_ns).as_secs_f64() / (365.25 * 24.0 * 60.0 * 60.0);
369 let price = compute_greeks(
370 underlying_price as f32,
371 strike as f32,
372 years_to_expiry as f32,
373 0.0,
374 0.0,
375 vol as f32,
376 is_call,
377 )
378 .price as f64;
379
380 if !price.is_finite() || price <= 0.0 {
381 anyhow::bail!(
382 "Computed non-positive premium for {instrument_id}: price={price}, \
383 underlying={underlying_price}, strike={strike}, {vol_source}={vol}"
384 );
385 }
386
387 log::info!(
388 "Premium quote unavailable for {instrument_id}; using {vol_source}={vol:.4}, \
389 underlying={underlying_price:.2}, strike={strike:.2}, t={years_to_expiry:.6}"
390 );
391
392 Ok(price)
393 }
394
395 fn submit_entry_order(
396 &mut self,
397 instrument_id: InstrumentId,
398 contracts: u64,
399 price: Price,
400 tif: TimeInForce,
401 client_id: ClientId,
402 params: Option<Params>,
403 ) -> anyhow::Result<()> {
404 let order = self.order().limit(
405 instrument_id,
406 OrderSide::Sell,
407 Quantity::new(contracts as f64, 0),
408 price,
409 Some(tif),
410 None,
411 None,
412 None,
413 None,
414 None,
415 None,
416 None,
417 None,
418 None,
419 None,
420 None,
421 );
422
423 self.submit_order(order, None, Some(client_id), params)
424 }
425
426 fn check_rehedge(&mut self) -> anyhow::Result<()> {
427 let delta = self.portfolio_delta();
428
429 if !self.should_rehedge() {
430 return Ok(());
431 }
432
433 if self.hedge_pending {
434 log::info!("Hedge order already pending, skipping rehedge");
435 return Ok(());
436 }
437
438 let hedge_qty = delta.abs();
439 let side = if delta > 0.0 {
440 OrderSide::Sell
441 } else {
442 OrderSide::Buy
443 };
444
445 let hedge_id = self.config.hedge_instrument_id;
446 let size_precision = {
447 let cache = self.cache();
448 cache
449 .instrument(&hedge_id)
450 .map_or(2, |i| i.size_precision())
451 };
452
453 let hedge_quantity = Quantity::new(hedge_qty, size_precision);
455
456 if hedge_quantity.is_zero() {
457 log::debug!(
458 "Rehedge delta {hedge_qty} rounds to zero at size precision {size_precision}, skipping"
459 );
460 return Ok(());
461 }
462
463 log::info!(
464 "Rehedging: portfolio_delta={delta:.4}, submitting {side:?} {hedge_quantity} on {hedge_id}",
465 );
466
467 let order = self.order().market(
468 hedge_id,
469 side,
470 hedge_quantity,
471 None,
472 None,
473 None,
474 None,
475 None,
476 None,
477 None,
478 );
479
480 self.hedge_pending = true;
481
482 if let Err(e) = self.submit_order(order, None, Some(self.config.client_id), None) {
483 self.hedge_pending = false;
484 return Err(e);
485 }
486
487 Ok(())
488 }
489}
490
491nautilus_strategy!(DeltaNeutralVol, {
492 fn on_order_filled(&mut self, event: &OrderFilled) {
493 let qty = event.last_qty.as_f64();
494 let signed_qty = match event.order_side {
495 OrderSide::Buy => qty,
496 OrderSide::Sell => -qty,
497 };
498
499 if event.instrument_id == self.config.hedge_instrument_id {
500 self.hedge_position += signed_qty;
501
502 let is_closed = self
503 .cache()
504 .order(&event.client_order_id)
505 .is_some_and(|o| o.is_closed());
506
507 if is_closed {
508 self.hedge_pending = false;
509 }
510 } else if Some(event.instrument_id) == self.call_instrument_id {
511 self.call_position += signed_qty;
512 } else if Some(event.instrument_id) == self.put_instrument_id {
513 self.put_position += signed_qty;
514 }
515
516 log::info!(
517 "Fill: {} {:.4} {} | positions: call={}, put={}, hedge={}",
518 event.order_side,
519 event.last_qty,
520 event.instrument_id,
521 self.call_position,
522 self.put_position,
523 self.hedge_position,
524 );
525 }
526
527 fn on_order_canceled(&mut self, event: &OrderCanceled) {
528 let instrument_id = self
529 .cache()
530 .order(&event.client_order_id)
531 .map(|o| o.instrument_id());
532
533 if instrument_id == Some(self.config.hedge_instrument_id) {
534 self.hedge_pending = false;
535 }
536 }
537
538 fn on_order_rejected(&mut self, event: OrderRejected) {
539 if event.instrument_id == self.config.hedge_instrument_id {
540 self.hedge_pending = false;
541 }
542 }
543
544 fn on_order_denied(&mut self, event: OrderDenied) {
545 if event.instrument_id == self.config.hedge_instrument_id {
546 self.hedge_pending = false;
547 }
548 }
549
550 fn on_order_expired(&mut self, event: OrderExpired) {
551 if event.instrument_id == self.config.hedge_instrument_id {
552 self.hedge_pending = false;
553 }
554 }
555});
556
557impl Debug for DeltaNeutralVol {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 f.debug_struct(stringify!(DeltaNeutralVol))
560 .field("config", &self.config)
561 .field("call_instrument_id", &self.call_instrument_id)
562 .field("put_instrument_id", &self.put_instrument_id)
563 .field("call_delta", &self.call_delta)
564 .field("put_delta", &self.put_delta)
565 .field("portfolio_delta", &self.portfolio_delta())
566 .finish()
567 }
568}
569
570impl DataActor for DeltaNeutralVol {
571 fn on_start(&mut self) -> anyhow::Result<()> {
572 let venue = self.config.hedge_instrument_id.venue;
573 let underlying = Ustr::from(&self.config.option_family);
574 let now_ns = self.clock().timestamp_ns().as_u64();
575
576 let mut calls: Vec<(InstrumentId, f64, u64)> = Vec::new();
577 let mut puts: Vec<(InstrumentId, f64, u64)> = Vec::new();
578
579 {
580 let cache = self.cache();
581 let instruments = cache.instruments(&venue, Some(&underlying));
582
583 for inst in &instruments {
584 let Some(expiry_ns) = inst.expiration_ns() else {
585 continue;
586 };
587
588 if expiry_ns.as_u64() <= now_ns {
589 continue;
590 }
591
592 if let Some(ref filter) = self.config.expiry_filter {
593 let symbol = inst.symbol().inner();
594 if !symbol.as_str().contains(filter.as_str()) {
595 continue;
596 }
597 }
598
599 let strike = match inst.strike_price() {
600 Some(p) => p.as_f64(),
601 None => continue,
602 };
603
604 match inst.option_kind() {
605 Some(OptionKind::Call) => {
606 calls.push((inst.id(), strike, expiry_ns.as_u64()));
607 }
608 Some(OptionKind::Put) => {
609 puts.push((inst.id(), strike, expiry_ns.as_u64()));
610 }
611 None => {}
612 }
613 }
614 }
615
616 if calls.is_empty() || puts.is_empty() {
617 log::warn!(
618 "Insufficient options found for family '{}': {} calls, {} puts",
619 self.config.option_family,
620 calls.len(),
621 puts.len(),
622 );
623 return Ok(());
624 }
625
626 if self.config.expiry_filter.is_none() {
627 let nearest = calls
628 .iter()
629 .chain(puts.iter())
630 .map(|(_, _, exp)| *exp)
631 .min()
632 .unwrap();
633 calls.retain(|(_, _, exp)| *exp == nearest);
634 puts.retain(|(_, _, exp)| *exp == nearest);
635 }
636
637 if calls.is_empty() || puts.is_empty() {
638 log::warn!(
639 "Nearest expiry has incomplete chain: {} calls, {} puts",
640 calls.len(),
641 puts.len(),
642 );
643 return Ok(());
644 }
645
646 log::info!(
647 "Found {} calls and {} puts for family '{}'",
648 calls.len(),
649 puts.len(),
650 self.config.option_family,
651 );
652
653 calls.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
658 puts.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
659
660 let call_idx = ((1.0 - self.config.target_call_delta) * calls.len() as f64) as usize;
662 let call_idx = call_idx.min(calls.len() - 1);
663 let (call_id, call_strike, _) = calls[call_idx];
664
665 let put_idx = (self.config.target_put_delta.abs() * puts.len() as f64) as usize;
667 let put_idx = put_idx.min(puts.len() - 1);
668 let (put_id, put_strike, _) = puts[put_idx];
669
670 self.call_instrument_id = Some(call_id);
671 self.put_instrument_id = Some(put_id);
672
673 log::info!("Selected call: {call_id} (strike={call_strike})");
674 log::info!("Selected put: {put_id} (strike={put_strike})");
675 log::info!(
676 "Strangle: {} contracts per leg, hedge on {}",
677 self.config.contracts,
678 self.config.hedge_instrument_id,
679 );
680
681 let (cached_call_pos, cached_put_pos, cached_hedge_pos) = {
682 let cache = self.cache();
683 let hedge_id = self.config.hedge_instrument_id;
684
685 let call_pos: f64 = cache
686 .positions_open(None, Some(&call_id), None, None, None)
687 .iter()
688 .map(|p| {
689 p.signed_qty
690 .to_f64()
691 .expect("Decimal quantity fits strategy analytics")
692 })
693 .sum();
694
695 let put_pos: f64 = cache
696 .positions_open(None, Some(&put_id), None, None, None)
697 .iter()
698 .map(|p| {
699 p.signed_qty
700 .to_f64()
701 .expect("Decimal quantity fits strategy analytics")
702 })
703 .sum();
704
705 let hedge_pos: f64 = cache
706 .positions_open(None, Some(&hedge_id), None, None, None)
707 .iter()
708 .map(|p| {
709 p.signed_qty
710 .to_f64()
711 .expect("Decimal quantity fits strategy analytics")
712 })
713 .sum();
714
715 (call_pos, put_pos, hedge_pos)
716 };
717
718 self.call_position = cached_call_pos;
719 self.put_position = cached_put_pos;
720 self.hedge_position = cached_hedge_pos;
721
722 if self.call_position != 0.0 || self.put_position != 0.0 || self.hedge_position != 0.0 {
723 log::info!(
724 "Hydrated positions: call={}, put={}, hedge={}",
725 self.call_position,
726 self.put_position,
727 self.hedge_position,
728 );
729 }
730
731 let client_id = self.config.client_id;
732
733 self.subscribe_option_greeks(call_id, Some(client_id), None);
734 self.subscribed_greeks.push(call_id);
735
736 self.subscribe_option_greeks(put_id, Some(client_id), None);
737 self.subscribed_greeks.push(put_id);
738
739 if self.config.enter_strangle && self.config.entry_premium_offset_ticks.is_some() {
740 self.subscribe_quotes(call_id, Some(client_id), None);
741 self.subscribe_quotes(put_id, Some(client_id), None);
742 }
743
744 self.subscribe_quotes(self.config.hedge_instrument_id, None, None);
745
746 let interval_ns = DurationNanos::try_from_secs(self.config.rehedge_interval_secs)?;
747 self.clock()
748 .set_timer_ns(REHEDGE_TIMER, interval_ns, None, None, None, None, None)?;
749
750 log::info!(
751 "Rehedge timer set: every {}s, threshold={}",
752 self.config.rehedge_interval_secs,
753 self.config.rehedge_delta_threshold,
754 );
755
756 if self.config.enter_strangle {
757 if let Some(offset_ticks) = self.config.entry_premium_offset_ticks {
758 log::info!(
759 "Strangle entry enabled: SELL {} x {call_id} (call) + SELL {} x {put_id} \
760 (put) once premium data arrives (ask_offset_ticks={offset_ticks})",
761 self.config.contracts,
762 self.config.contracts,
763 );
764 } else {
765 log::info!(
766 "Strangle entry enabled: SELL {} x {call_id} (call) + SELL {} x {put_id} \
767 (put) once Greeks arrive (iv_offset={})",
768 self.config.contracts,
769 self.config.contracts,
770 self.config.entry_iv_offset,
771 );
772 }
773 } else {
774 log::info!(
775 "Strangle entry disabled: hedging externally-held positions only. \
776 Monitoring {call_id} (call) + {put_id} (put)",
777 );
778 }
779
780 Ok(())
781 }
782
783 fn on_stop(&mut self) -> anyhow::Result<()> {
784 self.clock().cancel_timer(REHEDGE_TIMER);
785
786 let ids: Vec<InstrumentId> = std::mem::take(&mut self.subscribed_greeks);
787 let client_id = self.config.client_id;
788
789 for instrument_id in ids {
790 self.unsubscribe_option_greeks(instrument_id, Some(client_id), None);
791 }
792
793 let premium_entry_active =
794 self.config.enter_strangle && self.config.entry_premium_offset_ticks.is_some();
795
796 if let Some(call_id) = self.call_instrument_id {
797 if premium_entry_active {
798 self.unsubscribe_quotes(call_id, Some(client_id), None);
799 }
800 self.cancel_all_orders(call_id, None, None, true, None)?;
801 }
802
803 if let Some(put_id) = self.put_instrument_id {
804 if premium_entry_active {
805 self.unsubscribe_quotes(put_id, Some(client_id), None);
806 }
807 self.cancel_all_orders(put_id, None, None, true, None)?;
808 }
809
810 let hedge_id = self.config.hedge_instrument_id;
811 self.unsubscribe_quotes(hedge_id, None, None);
812 self.cancel_all_orders(hedge_id, None, None, true, None)?;
813 self.hedge_pending = false;
814
815 log::info!("Delta-neutral vol strategy stopped, positions left unchanged");
816
817 Ok(())
818 }
819
820 fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
821 if Some(greeks.instrument_id) == self.call_instrument_id {
822 self.call_greeks = Some(*greeks);
823 self.call_delta = greeks.greeks.delta;
824 self.call_delta_ready = true;
825
826 if let Some(iv) = greeks.mark_iv {
827 self.call_mark_iv = Some(iv);
828 }
829 } else if Some(greeks.instrument_id) == self.put_instrument_id {
830 self.put_greeks = Some(*greeks);
831 self.put_delta = greeks.greeks.delta;
832 self.put_delta_ready = true;
833
834 if let Some(iv) = greeks.mark_iv {
835 self.put_mark_iv = Some(iv);
836 }
837 }
838
839 let portfolio_delta = self.portfolio_delta();
840
841 log::info!(
842 "Greeks update: {} delta={:.4} | portfolio_delta={portfolio_delta:.4} \
843 (call={:.4}*{}, put={:.4}*{}, hedge={})",
844 greeks.instrument_id,
845 greeks.greeks.delta,
846 self.call_delta,
847 self.call_position,
848 self.put_delta,
849 self.put_position,
850 self.hedge_position,
851 );
852
853 self.enter_strangle()?;
854 self.check_rehedge()?;
855
856 Ok(())
857 }
858
859 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
860 if Some(quote.instrument_id) == self.call_instrument_id {
861 self.call_quote = Some(*quote);
862 log::debug!(
863 "Call quote: bid={} ask={} on {}",
864 quote.bid_price,
865 quote.ask_price,
866 quote.instrument_id,
867 );
868 self.enter_strangle()?;
869 } else if Some(quote.instrument_id) == self.put_instrument_id {
870 self.put_quote = Some(*quote);
871 log::debug!(
872 "Put quote: bid={} ask={} on {}",
873 quote.bid_price,
874 quote.ask_price,
875 quote.instrument_id,
876 );
877 self.enter_strangle()?;
878 } else if quote.instrument_id == self.config.hedge_instrument_id {
879 log::debug!(
880 "Hedge quote: bid={} ask={} on {}",
881 quote.bid_price,
882 quote.ask_price,
883 quote.instrument_id,
884 );
885 }
886
887 Ok(())
888 }
889
890 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
891 if event.name == REHEDGE_TIMER {
892 self.check_rehedge()?;
893 }
894
895 Ok(())
896 }
897}