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::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                .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        // A delta above the float threshold can still round to zero at the size precision.
451        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        // Strike price approximates delta ordering: higher strikes have
651        // lower call delta, lower strikes have more negative put delta.
652        // A production strategy would subscribe to all greeks first,
653        // then select strikes once actual deltas arrive.
654        calls.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
655        puts.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
656
657        // Select call at ~80th percentile strike (OTM, ~0.20 delta)
658        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        // Select put at ~20th percentile strike (OTM, ~-0.20 delta)
663        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}