Skip to main content

nautilus_trading/examples/strategies/delta_neutral_vol/
strategy.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Delta-neutral short volatility hedger implementation.
17
18use 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
44/// Delta-neutral short volatility hedger.
45///
46/// Tracks a short OTM call and put (strangle) on a configurable option
47/// family and delta-hedges the net Greek exposure with the underlying
48/// perpetual swap. Rehedges when portfolio delta exceeds a threshold
49/// or on a periodic timer.
50pub 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    /// Creates a new [`DeltaNeutralVol`] instance from config.
75    #[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    /// Computes the net portfolio delta across option legs and hedge position.
102    #[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    /// Returns `true` when both greeks legs have been initialized.
110    #[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    /// Returns `true` when portfolio delta exceeds the rehedge threshold.
119    #[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    /// Returns `true` when strangle entry can proceed.
126    #[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    /// Returns `true` when the configured entry pricing mode has enough data.
138    #[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    /// Returns `true` when any open or in-flight orders exist on the option legs.
194    #[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        // A delta above the float threshold can still round to zero at the size precision.
450        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        // Strike price approximates delta ordering: higher strikes have
650        // lower call delta, lower strikes have more negative put delta.
651        // A production strategy would subscribe to all greeks first,
652        // then select strikes once actual deltas arrive.
653        calls.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
654        puts.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
655
656        // Select call at ~80th percentile strike (OTM, ~0.20 delta)
657        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        // Select put at ~20th percentile strike (OTM, ~-0.20 delta)
662        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}