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