1use std::fmt::Debug;
19
20use anyhow::Context;
21use nautilus_common::{actor::DataActor, timer::TimeEvent};
22use nautilus_core::params::Params;
23use nautilus_model::{
24 data::{QuoteTick, black_scholes::compute_greeks, option_chain::OptionGreeks},
25 enums::{OptionKind, OrderSide, TimeInForce},
26 events::{OrderCanceled, 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
44pub struct DeltaNeutralVol {
51 pub(super) core: StrategyCore,
52 pub(super) config: DeltaNeutralVolConfig,
53 pub(super) call_instrument_id: Option<InstrumentId>,
54 pub(super) put_instrument_id: Option<InstrumentId>,
55 pub(super) subscribed_greeks: Vec<InstrumentId>,
56 pub(super) call_delta: f64,
57 pub(super) put_delta: f64,
58 pub(super) call_mark_iv: Option<f64>,
59 pub(super) put_mark_iv: Option<f64>,
60 pub(super) call_quote: Option<QuoteTick>,
61 pub(super) put_quote: Option<QuoteTick>,
62 pub(super) call_greeks: Option<OptionGreeks>,
63 pub(super) put_greeks: Option<OptionGreeks>,
64 pub(super) call_delta_ready: bool,
65 pub(super) put_delta_ready: bool,
66 pub(super) call_position: f64,
67 pub(super) put_position: f64,
68 pub(super) hedge_position: f64,
69 pub(super) hedge_pending: bool,
70 pub(super) entry_attempted: bool,
71}
72
73impl DeltaNeutralVol {
74 #[must_use]
76 pub fn new(config: DeltaNeutralVolConfig) -> Self {
77 Self {
78 core: StrategyCore::new(config.base.clone()),
79 call_instrument_id: None,
80 put_instrument_id: None,
81 subscribed_greeks: Vec::new(),
82 call_delta: 0.0,
83 put_delta: 0.0,
84 call_mark_iv: None,
85 put_mark_iv: None,
86 call_quote: None,
87 put_quote: None,
88 call_greeks: None,
89 put_greeks: None,
90 call_delta_ready: false,
91 put_delta_ready: false,
92 call_position: 0.0,
93 put_position: 0.0,
94 hedge_position: 0.0,
95 hedge_pending: false,
96 entry_attempted: false,
97 config,
98 }
99 }
100
101 #[must_use]
103 pub fn portfolio_delta(&self) -> f64 {
104 self.call_delta * self.call_position
105 + self.put_delta * self.put_position
106 + self.hedge_position
107 }
108
109 #[must_use]
111 pub fn greeks_initialized(&self) -> bool {
112 self.call_instrument_id.is_some()
113 && self.put_instrument_id.is_some()
114 && self.call_delta_ready
115 && self.put_delta_ready
116 }
117
118 #[must_use]
120 pub fn should_rehedge(&self) -> bool {
121 self.greeks_initialized()
122 && self.portfolio_delta().abs() > self.config.rehedge_delta_threshold
123 }
124
125 #[must_use]
127 pub fn should_enter_strangle(&self) -> bool {
128 self.config.enter_strangle
129 && self.greeks_initialized()
130 && self.entry_price_data_ready()
131 && self.call_position == 0.0
132 && self.put_position == 0.0
133 && !self.entry_attempted
134 && !self.has_working_entry_orders()
135 }
136
137 #[must_use]
139 pub fn entry_price_data_ready(&self) -> bool {
140 if self.config.entry_premium_offset_ticks.is_some() {
141 let Some(call_id) = self.call_instrument_id else {
142 return false;
143 };
144 let Some(put_id) = self.put_instrument_id else {
145 return false;
146 };
147
148 return self.premium_entry_data_ready(call_id, self.call_quote, self.call_greeks)
149 && self.premium_entry_data_ready(put_id, self.put_quote, self.put_greeks);
150 }
151
152 self.call_mark_iv.is_some() && self.put_mark_iv.is_some()
153 }
154
155 fn premium_entry_data_ready(
156 &self,
157 instrument_id: InstrumentId,
158 quote: Option<QuoteTick>,
159 greeks: Option<OptionGreeks>,
160 ) -> bool {
161 if quote.is_some_and(|q| q.ask_price.as_decimal() > Decimal::ZERO) {
162 return true;
163 }
164
165 let Some(greeks) = greeks else {
166 return false;
167 };
168
169 self.premium_from_greeks_ready(instrument_id, greeks)
170 }
171
172 fn premium_from_greeks_ready(&self, instrument_id: InstrumentId, greeks: OptionGreeks) -> bool {
173 let Some(underlying_price) = greeks.underlying_price else {
174 return false;
175 };
176 let Some(vol) = greeks.ask_iv.filter(|v| *v > 0.0).or(greeks.mark_iv) else {
177 return false;
178 };
179 let has_option_terms = {
180 let cache = self.cache();
181 let Some(instrument) = cache.instrument(&instrument_id) else {
182 return false;
183 };
184
185 instrument.strike_price().is_some()
186 && instrument.expiration_ns().is_some()
187 && instrument.option_kind().is_some()
188 };
189
190 underlying_price > 0.0 && vol > 0.0 && has_option_terms
191 }
192
193 #[must_use]
195 pub fn has_working_entry_orders(&self) -> bool {
196 let cache = self.cache();
197
198 for id in [self.call_instrument_id, self.put_instrument_id]
199 .into_iter()
200 .flatten()
201 {
202 let open = cache.orders_open(None, Some(&id), None, None, None);
203 let inflight = cache.orders_inflight(None, Some(&id), None, None, None);
204
205 if !open.is_empty() || !inflight.is_empty() {
206 return true;
207 }
208 }
209 false
210 }
211
212 fn enter_strangle(&mut self) -> anyhow::Result<()> {
213 if !self.should_enter_strangle() {
214 return Ok(());
215 }
216
217 let call_id = self.call_instrument_id.unwrap();
218 let put_id = self.put_instrument_id.unwrap();
219 let contracts = self.config.contracts;
220 let tif = self.config.entry_time_in_force;
221 let client_id = self.config.client_id;
222
223 if let Some(offset_ticks) = self.config.entry_premium_offset_ticks {
224 let call_price =
225 self.entry_premium_price(call_id, self.call_quote, self.call_greeks)?;
226 let put_price = self.entry_premium_price(put_id, self.put_quote, self.put_greeks)?;
227
228 log::info!(
229 "Entering strangle: SELL {contracts} x {call_id} @ premium={call_price} \
230 + SELL {contracts} x {put_id} @ premium={put_price} \
231 (ask_offset_ticks={offset_ticks})",
232 );
233
234 self.submit_entry_order(call_id, contracts, call_price, tif, client_id, None)?;
235 self.submit_entry_order(put_id, contracts, put_price, tif, client_id, None)?;
236 } else {
237 let call_iv = self.call_mark_iv.unwrap();
238 let put_iv = self.put_mark_iv.unwrap();
239 let offset = self.config.entry_iv_offset;
240 let call_entry_iv = call_iv - offset;
241 let put_entry_iv = put_iv - offset;
242
243 log::info!(
244 "Entering strangle: SELL {contracts} x {call_id} @ iv={call_entry_iv:.4} \
245 + SELL {contracts} x {put_id} @ iv={put_entry_iv:.4} (offset={offset})",
246 );
247
248 let mut call_params = Params::new();
249 call_params.insert(
250 self.config.iv_param_key.clone(),
251 json!(call_entry_iv.to_string()),
252 );
253
254 self.submit_entry_order(
255 call_id,
256 contracts,
257 Price::new(call_entry_iv, 4),
258 tif,
259 client_id,
260 Some(call_params),
261 )?;
262
263 let mut put_params = Params::new();
264 put_params.insert(
265 self.config.iv_param_key.clone(),
266 json!(put_entry_iv.to_string()),
267 );
268
269 self.submit_entry_order(
270 put_id,
271 contracts,
272 Price::new(put_entry_iv, 4),
273 tif,
274 client_id,
275 Some(put_params),
276 )?;
277 }
278
279 self.entry_attempted = true;
280
281 Ok(())
282 }
283
284 fn entry_premium_price(
285 &self,
286 instrument_id: InstrumentId,
287 quote: Option<QuoteTick>,
288 greeks: Option<OptionGreeks>,
289 ) -> anyhow::Result<Price> {
290 if let Some(quote) = quote
291 && quote.ask_price.as_decimal() > Decimal::ZERO
292 {
293 return self.offset_entry_price(instrument_id, quote.ask_price.as_f64());
294 }
295
296 let greeks = greeks.with_context(|| {
297 format!("missing quote and Greeks for premium entry on {instrument_id}")
298 })?;
299 let base_price = self.entry_premium_from_greeks(instrument_id, greeks)?;
300
301 self.offset_entry_price(instrument_id, base_price)
302 }
303
304 fn offset_entry_price(
305 &self,
306 instrument_id: InstrumentId,
307 base_price: f64,
308 ) -> anyhow::Result<Price> {
309 let offset_ticks = self
310 .config
311 .entry_premium_offset_ticks
312 .context("missing premium entry offset")?;
313
314 let cache = self.cache();
315 let instrument = cache.try_instrument(&instrument_id)?;
316
317 instrument
318 .next_ask_price(base_price, offset_ticks)
319 .with_context(|| {
320 format!(
321 "failed to offset premium for {instrument_id}: price={base_price}, ticks={offset_ticks}"
322 )
323 })
324 }
325
326 fn entry_premium_from_greeks(
327 &self,
328 instrument_id: InstrumentId,
329 greeks: OptionGreeks,
330 ) -> anyhow::Result<f64> {
331 let (strike, expiration_ns, is_call) = {
332 let cache = self.cache();
333 let instrument = cache.try_instrument(&instrument_id)?;
334 let strike = instrument
335 .strike_price()
336 .with_context(|| format!("missing strike for {instrument_id}"))?
337 .as_f64();
338 let expiration_ns = instrument
339 .expiration_ns()
340 .with_context(|| format!("missing expiry for {instrument_id}"))?
341 .as_u64();
342 let option_kind = instrument
343 .option_kind()
344 .with_context(|| format!("missing option kind for {instrument_id}"))?;
345 let is_call = matches!(option_kind, OptionKind::Call);
346
347 (strike, expiration_ns, is_call)
348 };
349 let now_ns = self.clock().timestamp_ns().as_u64();
350
351 if expiration_ns <= now_ns {
352 anyhow::bail!("Cannot price premium entry for expired instrument {instrument_id}");
353 }
354
355 let underlying_price = greeks
356 .underlying_price
357 .with_context(|| format!("missing underlying price for {instrument_id}"))?;
358 let (vol_source, vol) = greeks
359 .ask_iv
360 .filter(|v| *v > 0.0)
361 .map(|v| ("ask_iv", v))
362 .or_else(|| greeks.mark_iv.filter(|v| *v > 0.0).map(|v| ("mark_iv", v)))
363 .with_context(|| format!("missing positive IV for {instrument_id}"))?;
364 let years_to_expiry =
365 (expiration_ns - now_ns) as f64 / 1_000_000_000.0 / (365.25 * 24.0 * 60.0 * 60.0);
366 let price = compute_greeks(
367 underlying_price as f32,
368 strike as f32,
369 years_to_expiry as f32,
370 0.0,
371 0.0,
372 vol as f32,
373 is_call,
374 )
375 .price as f64;
376
377 if !price.is_finite() || price <= 0.0 {
378 anyhow::bail!(
379 "Computed non-positive premium for {instrument_id}: price={price}, \
380 underlying={underlying_price}, strike={strike}, {vol_source}={vol}"
381 );
382 }
383
384 log::info!(
385 "Premium quote unavailable for {instrument_id}; using {vol_source}={vol:.4}, \
386 underlying={underlying_price:.2}, strike={strike:.2}, t={years_to_expiry:.6}"
387 );
388
389 Ok(price)
390 }
391
392 fn submit_entry_order(
393 &mut self,
394 instrument_id: InstrumentId,
395 contracts: u64,
396 price: Price,
397 tif: TimeInForce,
398 client_id: ClientId,
399 params: Option<Params>,
400 ) -> anyhow::Result<()> {
401 let order = self.order().limit(
402 instrument_id,
403 OrderSide::Sell,
404 Quantity::new(contracts as f64, 0),
405 price,
406 Some(tif),
407 None,
408 None,
409 None,
410 None,
411 None,
412 None,
413 None,
414 None,
415 None,
416 None,
417 None,
418 );
419
420 self.submit_order(order, None, Some(client_id), params)
421 }
422
423 fn check_rehedge(&mut self) -> anyhow::Result<()> {
424 let delta = self.portfolio_delta();
425
426 if !self.should_rehedge() {
427 return Ok(());
428 }
429
430 if self.hedge_pending {
431 log::info!("Hedge order already pending, skipping rehedge");
432 return Ok(());
433 }
434
435 let hedge_qty = delta.abs();
436 let side = if delta > 0.0 {
437 OrderSide::Sell
438 } else {
439 OrderSide::Buy
440 };
441
442 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 calls.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
582 puts.sort_by(|(_, s1, _), (_, s2, _)| s1.partial_cmp(s2).unwrap());
583
584 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 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}