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, OrderFilled},
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        log::info!(
443            "Rehedging: portfolio_delta={delta:.4}, submitting {side:?} {hedge_qty:.4} on {}",
444            self.config.hedge_instrument_id,
445        );
446
447        let hedge_id = self.config.hedge_instrument_id;
448        let size_precision = {
449            let cache = self.cache();
450            cache
451                .instrument(&hedge_id)
452                .map_or(2, |i| i.size_precision())
453        };
454
455        let order = self.order().market(
456            hedge_id,
457            side,
458            Quantity::new(hedge_qty, size_precision),
459            None,
460            None,
461            None,
462            None,
463            None,
464            None,
465            None,
466        );
467
468        self.hedge_pending = true;
469
470        if let Err(e) = self.submit_order(order, None, Some(self.config.client_id), None) {
471            self.hedge_pending = false;
472            return Err(e);
473        }
474
475        Ok(())
476    }
477}
478
479nautilus_strategy!(DeltaNeutralVol);
480
481impl Debug for DeltaNeutralVol {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        f.debug_struct(stringify!(DeltaNeutralVol))
484            .field("config", &self.config)
485            .field("call_instrument_id", &self.call_instrument_id)
486            .field("put_instrument_id", &self.put_instrument_id)
487            .field("call_delta", &self.call_delta)
488            .field("put_delta", &self.put_delta)
489            .field("portfolio_delta", &self.portfolio_delta())
490            .finish()
491    }
492}
493
494impl DataActor for DeltaNeutralVol {
495    fn on_start(&mut self) -> anyhow::Result<()> {
496        let venue = self.config.hedge_instrument_id.venue;
497        let underlying = Ustr::from(&self.config.option_family);
498        let now_ns = self.clock().timestamp_ns().as_u64();
499
500        let mut calls: Vec<(InstrumentId, f64, u64)> = Vec::new();
501        let mut puts: Vec<(InstrumentId, f64, u64)> = Vec::new();
502
503        {
504            let cache = self.cache();
505            let instruments = cache.instruments(&venue, Some(&underlying));
506
507            for inst in &instruments {
508                let Some(expiry_ns) = inst.expiration_ns() else {
509                    continue;
510                };
511
512                if expiry_ns.as_u64() <= now_ns {
513                    continue;
514                }
515
516                if let Some(ref filter) = self.config.expiry_filter {
517                    let symbol = inst.symbol().inner();
518                    if !symbol.as_str().contains(filter.as_str()) {
519                        continue;
520                    }
521                }
522
523                let strike = match inst.strike_price() {
524                    Some(p) => p.as_f64(),
525                    None => continue,
526                };
527
528                match inst.option_kind() {
529                    Some(OptionKind::Call) => {
530                        calls.push((inst.id(), strike, expiry_ns.as_u64()));
531                    }
532                    Some(OptionKind::Put) => {
533                        puts.push((inst.id(), strike, expiry_ns.as_u64()));
534                    }
535                    None => {}
536                }
537            }
538        }
539
540        if calls.is_empty() || puts.is_empty() {
541            log::warn!(
542                "Insufficient options found for family '{}': {} calls, {} puts",
543                self.config.option_family,
544                calls.len(),
545                puts.len(),
546            );
547            return Ok(());
548        }
549
550        if self.config.expiry_filter.is_none() {
551            let nearest = calls
552                .iter()
553                .chain(puts.iter())
554                .map(|(_, _, exp)| *exp)
555                .min()
556                .unwrap();
557            calls.retain(|(_, _, exp)| *exp == nearest);
558            puts.retain(|(_, _, exp)| *exp == nearest);
559        }
560
561        if calls.is_empty() || puts.is_empty() {
562            log::warn!(
563                "Nearest expiry has incomplete chain: {} calls, {} puts",
564                calls.len(),
565                puts.len(),
566            );
567            return Ok(());
568        }
569
570        log::info!(
571            "Found {} calls and {} puts for family '{}'",
572            calls.len(),
573            puts.len(),
574            self.config.option_family,
575        );
576
577        // Strike price approximates delta ordering: higher strikes have
578        // lower call delta, lower strikes have more negative put delta.
579        // A production strategy would subscribe to all greeks first,
580        // then select strikes once actual deltas arrive.
581        calls.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
582        puts.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
583
584        // Select call at ~80th percentile strike (OTM, ~0.20 delta)
585        let call_idx = ((1.0 - self.config.target_call_delta) * calls.len() as f64) as usize;
586        let call_idx = call_idx.min(calls.len() - 1);
587        let (call_id, call_strike, _) = calls[call_idx];
588
589        // Select put at ~20th percentile strike (OTM, ~-0.20 delta)
590        let put_idx = (self.config.target_put_delta.abs() * puts.len() as f64) as usize;
591        let put_idx = put_idx.min(puts.len() - 1);
592        let (put_id, put_strike, _) = puts[put_idx];
593
594        self.call_instrument_id = Some(call_id);
595        self.put_instrument_id = Some(put_id);
596
597        log::info!("Selected call: {call_id} (strike={call_strike})");
598        log::info!("Selected put: {put_id} (strike={put_strike})");
599        log::info!(
600            "Strangle: {} contracts per leg, hedge on {}",
601            self.config.contracts,
602            self.config.hedge_instrument_id,
603        );
604
605        let (cached_call_pos, cached_put_pos, cached_hedge_pos) = {
606            let cache = self.cache();
607            let hedge_id = self.config.hedge_instrument_id;
608
609            let call_pos: f64 = cache
610                .positions_open(None, Some(&call_id), None, None, None)
611                .iter()
612                .map(|p| p.signed_qty)
613                .sum();
614
615            let put_pos: f64 = cache
616                .positions_open(None, Some(&put_id), None, None, None)
617                .iter()
618                .map(|p| p.signed_qty)
619                .sum();
620
621            let hedge_pos: f64 = cache
622                .positions_open(None, Some(&hedge_id), None, None, None)
623                .iter()
624                .map(|p| p.signed_qty)
625                .sum();
626
627            (call_pos, put_pos, hedge_pos)
628        };
629
630        self.call_position = cached_call_pos;
631        self.put_position = cached_put_pos;
632        self.hedge_position = cached_hedge_pos;
633
634        if self.call_position != 0.0 || self.put_position != 0.0 || self.hedge_position != 0.0 {
635            log::info!(
636                "Hydrated positions: call={}, put={}, hedge={}",
637                self.call_position,
638                self.put_position,
639                self.hedge_position,
640            );
641        }
642
643        let client_id = self.config.client_id;
644
645        self.subscribe_option_greeks(call_id, Some(client_id), None);
646        self.subscribed_greeks.push(call_id);
647
648        self.subscribe_option_greeks(put_id, Some(client_id), None);
649        self.subscribed_greeks.push(put_id);
650
651        if self.config.enter_strangle && self.config.entry_premium_offset_ticks.is_some() {
652            self.subscribe_quotes(call_id, Some(client_id), None);
653            self.subscribe_quotes(put_id, Some(client_id), None);
654        }
655
656        self.subscribe_quotes(self.config.hedge_instrument_id, None, None);
657
658        let interval_ns = self.config.rehedge_interval_secs * 1_000_000_000;
659        self.clock()
660            .set_timer_ns(REHEDGE_TIMER, interval_ns, None, None, None, None, None)?;
661
662        log::info!(
663            "Rehedge timer set: every {}s, threshold={}",
664            self.config.rehedge_interval_secs,
665            self.config.rehedge_delta_threshold,
666        );
667
668        if self.config.enter_strangle {
669            if let Some(offset_ticks) = self.config.entry_premium_offset_ticks {
670                log::info!(
671                    "Strangle entry enabled: SELL {} x {call_id} (call) + SELL {} x {put_id} \
672                     (put) once premium data arrives (ask_offset_ticks={offset_ticks})",
673                    self.config.contracts,
674                    self.config.contracts,
675                );
676            } else {
677                log::info!(
678                    "Strangle entry enabled: SELL {} x {call_id} (call) + SELL {} x {put_id} \
679                     (put) once Greeks arrive (iv_offset={})",
680                    self.config.contracts,
681                    self.config.contracts,
682                    self.config.entry_iv_offset,
683                );
684            }
685        } else {
686            log::info!(
687                "Strangle entry disabled: hedging externally-held positions only. \
688                 Monitoring {call_id} (call) + {put_id} (put)",
689            );
690        }
691
692        Ok(())
693    }
694
695    fn on_stop(&mut self) -> anyhow::Result<()> {
696        self.clock().cancel_timer(REHEDGE_TIMER);
697
698        let ids: Vec<InstrumentId> = self.subscribed_greeks.drain(..).collect();
699        let client_id = self.config.client_id;
700
701        for instrument_id in ids {
702            self.unsubscribe_option_greeks(instrument_id, Some(client_id), None);
703        }
704
705        let premium_entry_active =
706            self.config.enter_strangle && self.config.entry_premium_offset_ticks.is_some();
707
708        if let Some(call_id) = self.call_instrument_id {
709            if premium_entry_active {
710                self.unsubscribe_quotes(call_id, Some(client_id), None);
711            }
712            self.cancel_all_orders(call_id, None, None, None)?;
713        }
714
715        if let Some(put_id) = self.put_instrument_id {
716            if premium_entry_active {
717                self.unsubscribe_quotes(put_id, Some(client_id), None);
718            }
719            self.cancel_all_orders(put_id, None, None, None)?;
720        }
721
722        let hedge_id = self.config.hedge_instrument_id;
723        self.unsubscribe_quotes(hedge_id, None, None);
724        self.cancel_all_orders(hedge_id, None, None, None)?;
725        self.hedge_pending = false;
726
727        log::info!("Delta-neutral vol strategy stopped, positions left unchanged");
728
729        Ok(())
730    }
731
732    fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
733        if Some(greeks.instrument_id) == self.call_instrument_id {
734            self.call_greeks = Some(*greeks);
735            self.call_delta = greeks.greeks.delta;
736            self.call_delta_ready = true;
737
738            if let Some(iv) = greeks.mark_iv {
739                self.call_mark_iv = Some(iv);
740            }
741        } else if Some(greeks.instrument_id) == self.put_instrument_id {
742            self.put_greeks = Some(*greeks);
743            self.put_delta = greeks.greeks.delta;
744            self.put_delta_ready = true;
745
746            if let Some(iv) = greeks.mark_iv {
747                self.put_mark_iv = Some(iv);
748            }
749        }
750
751        let portfolio_delta = self.portfolio_delta();
752
753        log::info!(
754            "Greeks update: {} delta={:.4} | portfolio_delta={portfolio_delta:.4} \
755             (call={:.4}*{}, put={:.4}*{}, hedge={})",
756            greeks.instrument_id,
757            greeks.greeks.delta,
758            self.call_delta,
759            self.call_position,
760            self.put_delta,
761            self.put_position,
762            self.hedge_position,
763        );
764
765        self.enter_strangle()?;
766        self.check_rehedge()?;
767
768        Ok(())
769    }
770
771    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
772        if Some(quote.instrument_id) == self.call_instrument_id {
773            self.call_quote = Some(*quote);
774            log::debug!(
775                "Call quote: bid={} ask={} on {}",
776                quote.bid_price,
777                quote.ask_price,
778                quote.instrument_id,
779            );
780            self.enter_strangle()?;
781        } else if Some(quote.instrument_id) == self.put_instrument_id {
782            self.put_quote = Some(*quote);
783            log::debug!(
784                "Put quote: bid={} ask={} on {}",
785                quote.bid_price,
786                quote.ask_price,
787                quote.instrument_id,
788            );
789            self.enter_strangle()?;
790        } else if quote.instrument_id == self.config.hedge_instrument_id {
791            log::debug!(
792                "Hedge quote: bid={} ask={} on {}",
793                quote.bid_price,
794                quote.ask_price,
795                quote.instrument_id,
796            );
797        }
798
799        Ok(())
800    }
801
802    fn on_order_filled(&mut self, event: &OrderFilled) -> anyhow::Result<()> {
803        let qty = event.last_qty.as_f64();
804        let signed_qty = match event.order_side {
805            OrderSide::Buy => qty,
806            OrderSide::Sell => -qty,
807            _ => 0.0,
808        };
809
810        if event.instrument_id == self.config.hedge_instrument_id {
811            self.hedge_position += signed_qty;
812
813            let is_closed = self
814                .cache()
815                .order(&event.client_order_id)
816                .is_some_and(|o| o.is_closed());
817
818            if is_closed {
819                self.hedge_pending = false;
820            }
821        } else if Some(event.instrument_id) == self.call_instrument_id {
822            self.call_position += signed_qty;
823        } else if Some(event.instrument_id) == self.put_instrument_id {
824            self.put_position += signed_qty;
825        }
826
827        log::info!(
828            "Fill: {} {:.4} {} | positions: call={}, put={}, hedge={}",
829            event.order_side,
830            event.last_qty,
831            event.instrument_id,
832            self.call_position,
833            self.put_position,
834            self.hedge_position,
835        );
836
837        Ok(())
838    }
839
840    fn on_order_canceled(&mut self, event: &OrderCanceled) -> anyhow::Result<()> {
841        let instrument_id = self
842            .cache()
843            .order(&event.client_order_id)
844            .map(|o| o.instrument_id());
845
846        if instrument_id == Some(self.config.hedge_instrument_id) {
847            self.hedge_pending = false;
848        }
849
850        Ok(())
851    }
852
853    fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
854        if event.name.as_str() == REHEDGE_TIMER {
855            self.check_rehedge()?;
856        }
857
858        Ok(())
859    }
860}