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