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;
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 mut call_params = Params::new();
249 call_params.insert(
250 self.config.iv_param_key.clone(),
251 json!(call_entry_iv.to_string()),
252 );
253
254 self.submit_entry_order(
255 call_id,
256 contracts,
257 Price::new(call_entry_iv, 4),
258 tif,
259 client_id,
260 Some(call_params),
261 )?;
262
263 let mut put_params = Params::new();
264 put_params.insert(
265 self.config.iv_param_key.clone(),
266 json!(put_entry_iv.to_string()),
267 );
268
269 self.submit_entry_order(
270 put_id,
271 contracts,
272 Price::new(put_entry_iv, 4),
273 tif,
274 client_id,
275 Some(put_params),
276 )?;
277 }
278
279 self.entry_attempted = true;
280
281 Ok(())
282 }
283
284 fn entry_premium_price(
285 &self,
286 instrument_id: InstrumentId,
287 quote: Option<QuoteTick>,
288 greeks: Option<OptionGreeks>,
289 ) -> anyhow::Result<Price> {
290 if let Some(quote) = quote
291 && quote.ask_price.as_decimal() > Decimal::ZERO
292 {
293 return self.offset_entry_price(instrument_id, quote.ask_price.as_f64());
294 }
295
296 let greeks = greeks.with_context(|| {
297 format!("missing quote and Greeks for premium entry on {instrument_id}")
298 })?;
299 let base_price = self.entry_premium_from_greeks(instrument_id, greeks)?;
300
301 self.offset_entry_price(instrument_id, base_price)
302 }
303
304 fn offset_entry_price(
305 &self,
306 instrument_id: InstrumentId,
307 base_price: f64,
308 ) -> anyhow::Result<Price> {
309 let offset_ticks = self
310 .config
311 .entry_premium_offset_ticks
312 .context("missing premium entry offset")?;
313
314 let cache = self.cache();
315 let instrument = cache.try_instrument(&instrument_id)?;
316
317 instrument
318 .next_ask_price(base_price, offset_ticks)
319 .with_context(|| {
320 format!(
321 "failed to offset premium for {instrument_id}: price={base_price}, ticks={offset_ticks}"
322 )
323 })
324 }
325
326 fn entry_premium_from_greeks(
327 &self,
328 instrument_id: InstrumentId,
329 greeks: OptionGreeks,
330 ) -> anyhow::Result<f64> {
331 let (strike, expiration_ns, is_call) = {
332 let cache = self.cache();
333 let instrument = cache.try_instrument(&instrument_id)?;
334 let strike = instrument
335 .strike_price()
336 .with_context(|| format!("missing strike for {instrument_id}"))?
337 .as_f64();
338 let expiration_ns = instrument
339 .expiration_ns()
340 .with_context(|| format!("missing expiry for {instrument_id}"))?;
341 let option_kind = instrument
342 .option_kind()
343 .with_context(|| format!("missing option kind for {instrument_id}"))?;
344 let is_call = matches!(option_kind, OptionKind::Call);
345
346 (strike, expiration_ns, is_call)
347 };
348 let now_ns = self.clock().timestamp_ns();
349
350 if expiration_ns <= now_ns {
351 anyhow::bail!("Cannot price premium entry for expired instrument {instrument_id}");
352 }
353
354 let underlying_price = greeks
355 .underlying_price
356 .with_context(|| format!("missing underlying price for {instrument_id}"))?;
357 let (vol_source, vol) = greeks
358 .ask_iv
359 .filter(|v| *v > 0.0)
360 .map(|v| ("ask_iv", v))
361 .or_else(|| greeks.mark_iv.filter(|v| *v > 0.0).map(|v| ("mark_iv", v)))
362 .with_context(|| format!("missing positive IV for {instrument_id}"))?;
363 let years_to_expiry =
364 (expiration_ns - now_ns).as_secs_f64() / (365.25 * 24.0 * 60.0 * 60.0);
365 let price = compute_greeks(
366 underlying_price as f32,
367 strike as f32,
368 years_to_expiry as f32,
369 0.0,
370 0.0,
371 vol as f32,
372 is_call,
373 )
374 .price as f64;
375
376 if !price.is_finite() || price <= 0.0 {
377 anyhow::bail!(
378 "Computed non-positive premium for {instrument_id}: price={price}, \
379 underlying={underlying_price}, strike={strike}, {vol_source}={vol}"
380 );
381 }
382
383 log::info!(
384 "Premium quote unavailable for {instrument_id}; using {vol_source}={vol:.4}, \
385 underlying={underlying_price:.2}, strike={strike:.2}, t={years_to_expiry:.6}"
386 );
387
388 Ok(price)
389 }
390
391 fn submit_entry_order(
392 &mut self,
393 instrument_id: InstrumentId,
394 contracts: u64,
395 price: Price,
396 tif: TimeInForce,
397 client_id: ClientId,
398 params: Option<Params>,
399 ) -> anyhow::Result<()> {
400 let order = self.order().limit(
401 instrument_id,
402 OrderSide::Sell,
403 Quantity::new(contracts as f64, 0),
404 price,
405 Some(tif),
406 None,
407 None,
408 None,
409 None,
410 None,
411 None,
412 None,
413 None,
414 None,
415 None,
416 None,
417 );
418
419 self.submit_order(order, None, Some(client_id), params)
420 }
421
422 fn check_rehedge(&mut self) -> anyhow::Result<()> {
423 let delta = self.portfolio_delta();
424
425 if !self.should_rehedge() {
426 return Ok(());
427 }
428
429 if self.hedge_pending {
430 log::info!("Hedge order already pending, skipping rehedge");
431 return Ok(());
432 }
433
434 let hedge_qty = delta.abs();
435 let side = if delta > 0.0 {
436 OrderSide::Sell
437 } else {
438 OrderSide::Buy
439 };
440
441 let hedge_id = self.config.hedge_instrument_id;
442 let size_precision = {
443 let cache = self.cache();
444 cache
445 .instrument(&hedge_id)
446 .map_or(2, |i| i.size_precision())
447 };
448
449 let hedge_quantity = Quantity::new(hedge_qty, size_precision);
451
452 if hedge_quantity.is_zero() {
453 log::debug!(
454 "Rehedge delta {hedge_qty} rounds to zero at size precision {size_precision}, skipping"
455 );
456 return Ok(());
457 }
458
459 log::info!(
460 "Rehedging: portfolio_delta={delta:.4}, submitting {side:?} {hedge_quantity} on {hedge_id}",
461 );
462
463 let order = self.order().market(
464 hedge_id,
465 side,
466 hedge_quantity,
467 None,
468 None,
469 None,
470 None,
471 None,
472 None,
473 None,
474 );
475
476 self.hedge_pending = true;
477
478 if let Err(e) = self.submit_order(order, None, Some(self.config.client_id), None) {
479 self.hedge_pending = false;
480 return Err(e);
481 }
482
483 Ok(())
484 }
485}
486
487nautilus_strategy!(DeltaNeutralVol, {
488 fn on_order_filled(&mut self, event: &OrderFilled) {
489 let qty = event.last_qty.as_f64();
490 let signed_qty = match event.order_side {
491 OrderSide::Buy => qty,
492 OrderSide::Sell => -qty,
493 };
494
495 if event.instrument_id == self.config.hedge_instrument_id {
496 self.hedge_position += signed_qty;
497
498 let is_closed = self
499 .cache()
500 .order(&event.client_order_id)
501 .is_some_and(|o| o.is_closed());
502
503 if is_closed {
504 self.hedge_pending = false;
505 }
506 } else if Some(event.instrument_id) == self.call_instrument_id {
507 self.call_position += signed_qty;
508 } else if Some(event.instrument_id) == self.put_instrument_id {
509 self.put_position += signed_qty;
510 }
511
512 log::info!(
513 "Fill: {} {:.4} {} | positions: call={}, put={}, hedge={}",
514 event.order_side,
515 event.last_qty,
516 event.instrument_id,
517 self.call_position,
518 self.put_position,
519 self.hedge_position,
520 );
521 }
522
523 fn on_order_canceled(&mut self, event: &OrderCanceled) {
524 let instrument_id = self
525 .cache()
526 .order(&event.client_order_id)
527 .map(|o| o.instrument_id());
528
529 if instrument_id == Some(self.config.hedge_instrument_id) {
530 self.hedge_pending = false;
531 }
532 }
533
534 fn on_order_rejected(&mut self, event: OrderRejected) {
535 if event.instrument_id == self.config.hedge_instrument_id {
536 self.hedge_pending = false;
537 }
538 }
539
540 fn on_order_denied(&mut self, event: OrderDenied) {
541 if event.instrument_id == self.config.hedge_instrument_id {
542 self.hedge_pending = false;
543 }
544 }
545
546 fn on_order_expired(&mut self, event: OrderExpired) {
547 if event.instrument_id == self.config.hedge_instrument_id {
548 self.hedge_pending = false;
549 }
550 }
551});
552
553impl Debug for DeltaNeutralVol {
554 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555 f.debug_struct(stringify!(DeltaNeutralVol))
556 .field("config", &self.config)
557 .field("call_instrument_id", &self.call_instrument_id)
558 .field("put_instrument_id", &self.put_instrument_id)
559 .field("call_delta", &self.call_delta)
560 .field("put_delta", &self.put_delta)
561 .field("portfolio_delta", &self.portfolio_delta())
562 .finish()
563 }
564}
565
566impl DataActor for DeltaNeutralVol {
567 fn on_start(&mut self) -> anyhow::Result<()> {
568 let venue = self.config.hedge_instrument_id.venue;
569 let underlying = Ustr::from(&self.config.option_family);
570 let now_ns = self.clock().timestamp_ns().as_u64();
571
572 let mut calls: Vec<(InstrumentId, f64, u64)> = Vec::new();
573 let mut puts: Vec<(InstrumentId, f64, u64)> = Vec::new();
574
575 {
576 let cache = self.cache();
577 let instruments = cache.instruments(&venue, Some(&underlying));
578
579 for inst in &instruments {
580 let Some(expiry_ns) = inst.expiration_ns() else {
581 continue;
582 };
583
584 if expiry_ns.as_u64() <= now_ns {
585 continue;
586 }
587
588 if let Some(ref filter) = self.config.expiry_filter {
589 let symbol = inst.symbol().inner();
590 if !symbol.as_str().contains(filter.as_str()) {
591 continue;
592 }
593 }
594
595 let strike = match inst.strike_price() {
596 Some(p) => p.as_f64(),
597 None => continue,
598 };
599
600 match inst.option_kind() {
601 Some(OptionKind::Call) => {
602 calls.push((inst.id(), strike, expiry_ns.as_u64()));
603 }
604 Some(OptionKind::Put) => {
605 puts.push((inst.id(), strike, expiry_ns.as_u64()));
606 }
607 None => {}
608 }
609 }
610 }
611
612 if calls.is_empty() || puts.is_empty() {
613 log::warn!(
614 "Insufficient options found for family '{}': {} calls, {} puts",
615 self.config.option_family,
616 calls.len(),
617 puts.len(),
618 );
619 return Ok(());
620 }
621
622 if self.config.expiry_filter.is_none() {
623 let nearest = calls
624 .iter()
625 .chain(puts.iter())
626 .map(|(_, _, exp)| *exp)
627 .min()
628 .unwrap();
629 calls.retain(|(_, _, exp)| *exp == nearest);
630 puts.retain(|(_, _, exp)| *exp == nearest);
631 }
632
633 if calls.is_empty() || puts.is_empty() {
634 log::warn!(
635 "Nearest expiry has incomplete chain: {} calls, {} puts",
636 calls.len(),
637 puts.len(),
638 );
639 return Ok(());
640 }
641
642 log::info!(
643 "Found {} calls and {} puts for family '{}'",
644 calls.len(),
645 puts.len(),
646 self.config.option_family,
647 );
648
649 calls.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
654 puts.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
655
656 let call_idx = ((1.0 - self.config.target_call_delta) * calls.len() as f64) as usize;
658 let call_idx = call_idx.min(calls.len() - 1);
659 let (call_id, call_strike, _) = calls[call_idx];
660
661 let put_idx = (self.config.target_put_delta.abs() * puts.len() as f64) as usize;
663 let put_idx = put_idx.min(puts.len() - 1);
664 let (put_id, put_strike, _) = puts[put_idx];
665
666 self.call_instrument_id = Some(call_id);
667 self.put_instrument_id = Some(put_id);
668
669 log::info!("Selected call: {call_id} (strike={call_strike})");
670 log::info!("Selected put: {put_id} (strike={put_strike})");
671 log::info!(
672 "Strangle: {} contracts per leg, hedge on {}",
673 self.config.contracts,
674 self.config.hedge_instrument_id,
675 );
676
677 let (cached_call_pos, cached_put_pos, cached_hedge_pos) = {
678 let cache = self.cache();
679 let hedge_id = self.config.hedge_instrument_id;
680
681 let call_pos: f64 = cache
682 .positions_open(None, Some(&call_id), None, None, None)
683 .iter()
684 .map(|p| p.signed_qty)
685 .sum();
686
687 let put_pos: f64 = cache
688 .positions_open(None, Some(&put_id), None, None, None)
689 .iter()
690 .map(|p| p.signed_qty)
691 .sum();
692
693 let hedge_pos: f64 = cache
694 .positions_open(None, Some(&hedge_id), None, None, None)
695 .iter()
696 .map(|p| p.signed_qty)
697 .sum();
698
699 (call_pos, put_pos, hedge_pos)
700 };
701
702 self.call_position = cached_call_pos;
703 self.put_position = cached_put_pos;
704 self.hedge_position = cached_hedge_pos;
705
706 if self.call_position != 0.0 || self.put_position != 0.0 || self.hedge_position != 0.0 {
707 log::info!(
708 "Hydrated positions: call={}, put={}, hedge={}",
709 self.call_position,
710 self.put_position,
711 self.hedge_position,
712 );
713 }
714
715 let client_id = self.config.client_id;
716
717 self.subscribe_option_greeks(call_id, Some(client_id), None);
718 self.subscribed_greeks.push(call_id);
719
720 self.subscribe_option_greeks(put_id, Some(client_id), None);
721 self.subscribed_greeks.push(put_id);
722
723 if self.config.enter_strangle && self.config.entry_premium_offset_ticks.is_some() {
724 self.subscribe_quotes(call_id, Some(client_id), None);
725 self.subscribe_quotes(put_id, Some(client_id), None);
726 }
727
728 self.subscribe_quotes(self.config.hedge_instrument_id, None, None);
729
730 let interval_ns = DurationNanos::try_from_secs(self.config.rehedge_interval_secs)?;
731 self.clock()
732 .set_timer_ns(REHEDGE_TIMER, interval_ns, None, None, None, None, None)?;
733
734 log::info!(
735 "Rehedge timer set: every {}s, threshold={}",
736 self.config.rehedge_interval_secs,
737 self.config.rehedge_delta_threshold,
738 );
739
740 if self.config.enter_strangle {
741 if let Some(offset_ticks) = self.config.entry_premium_offset_ticks {
742 log::info!(
743 "Strangle entry enabled: SELL {} x {call_id} (call) + SELL {} x {put_id} \
744 (put) once premium data arrives (ask_offset_ticks={offset_ticks})",
745 self.config.contracts,
746 self.config.contracts,
747 );
748 } else {
749 log::info!(
750 "Strangle entry enabled: SELL {} x {call_id} (call) + SELL {} x {put_id} \
751 (put) once Greeks arrive (iv_offset={})",
752 self.config.contracts,
753 self.config.contracts,
754 self.config.entry_iv_offset,
755 );
756 }
757 } else {
758 log::info!(
759 "Strangle entry disabled: hedging externally-held positions only. \
760 Monitoring {call_id} (call) + {put_id} (put)",
761 );
762 }
763
764 Ok(())
765 }
766
767 fn on_stop(&mut self) -> anyhow::Result<()> {
768 self.clock().cancel_timer(REHEDGE_TIMER);
769
770 let ids: Vec<InstrumentId> = std::mem::take(&mut self.subscribed_greeks);
771 let client_id = self.config.client_id;
772
773 for instrument_id in ids {
774 self.unsubscribe_option_greeks(instrument_id, Some(client_id), None);
775 }
776
777 let premium_entry_active =
778 self.config.enter_strangle && self.config.entry_premium_offset_ticks.is_some();
779
780 if let Some(call_id) = self.call_instrument_id {
781 if premium_entry_active {
782 self.unsubscribe_quotes(call_id, Some(client_id), None);
783 }
784 self.cancel_all_orders(call_id, None, None, true, None)?;
785 }
786
787 if let Some(put_id) = self.put_instrument_id {
788 if premium_entry_active {
789 self.unsubscribe_quotes(put_id, Some(client_id), None);
790 }
791 self.cancel_all_orders(put_id, None, None, true, None)?;
792 }
793
794 let hedge_id = self.config.hedge_instrument_id;
795 self.unsubscribe_quotes(hedge_id, None, None);
796 self.cancel_all_orders(hedge_id, None, None, true, None)?;
797 self.hedge_pending = false;
798
799 log::info!("Delta-neutral vol strategy stopped, positions left unchanged");
800
801 Ok(())
802 }
803
804 fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
805 if Some(greeks.instrument_id) == self.call_instrument_id {
806 self.call_greeks = Some(*greeks);
807 self.call_delta = greeks.greeks.delta;
808 self.call_delta_ready = true;
809
810 if let Some(iv) = greeks.mark_iv {
811 self.call_mark_iv = Some(iv);
812 }
813 } else if Some(greeks.instrument_id) == self.put_instrument_id {
814 self.put_greeks = Some(*greeks);
815 self.put_delta = greeks.greeks.delta;
816 self.put_delta_ready = true;
817
818 if let Some(iv) = greeks.mark_iv {
819 self.put_mark_iv = Some(iv);
820 }
821 }
822
823 let portfolio_delta = self.portfolio_delta();
824
825 log::info!(
826 "Greeks update: {} delta={:.4} | portfolio_delta={portfolio_delta:.4} \
827 (call={:.4}*{}, put={:.4}*{}, hedge={})",
828 greeks.instrument_id,
829 greeks.greeks.delta,
830 self.call_delta,
831 self.call_position,
832 self.put_delta,
833 self.put_position,
834 self.hedge_position,
835 );
836
837 self.enter_strangle()?;
838 self.check_rehedge()?;
839
840 Ok(())
841 }
842
843 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
844 if Some(quote.instrument_id) == self.call_instrument_id {
845 self.call_quote = Some(*quote);
846 log::debug!(
847 "Call quote: bid={} ask={} on {}",
848 quote.bid_price,
849 quote.ask_price,
850 quote.instrument_id,
851 );
852 self.enter_strangle()?;
853 } else if Some(quote.instrument_id) == self.put_instrument_id {
854 self.put_quote = Some(*quote);
855 log::debug!(
856 "Put quote: bid={} ask={} on {}",
857 quote.bid_price,
858 quote.ask_price,
859 quote.instrument_id,
860 );
861 self.enter_strangle()?;
862 } else if quote.instrument_id == self.config.hedge_instrument_id {
863 log::debug!(
864 "Hedge quote: bid={} ask={} on {}",
865 quote.bid_price,
866 quote.ask_price,
867 quote.instrument_id,
868 );
869 }
870
871 Ok(())
872 }
873
874 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
875 if event.name == REHEDGE_TIMER {
876 self.check_rehedge()?;
877 }
878
879 Ok(())
880 }
881}