1use ibapi::contracts::{OptionComputation, tick_types::TickType};
19use nautilus_core::UnixNanos;
20use nautilus_model::{
21 data::{
22 Bar, BarType, IndexPriceUpdate, QuoteTick, TradeTick, greeks::OptionGreekValues,
23 option_chain::OptionGreeks,
24 },
25 enums::{AggressorSide, BookAction, GreeksConvention},
26 identifiers::{InstrumentId, TradeId},
27 types::{Price, Quantity},
28};
29use thiserror::Error;
30
31#[derive(Debug, PartialEq, Error)]
32#[error("quantity size={size} cannot be represented with precision={precision}")]
33struct QuantityPrecisionError {
34 size: f64,
35 precision: u8,
36}
37
38fn checked_quantity(size: f64, precision: u8) -> anyhow::Result<Quantity> {
39 let quantity = Quantity::new_checked(size, precision)
40 .map_err(|e| anyhow::anyhow!("invalid quantity size={size}: {e}"))?;
41 let tolerance = 10_f64.powi(-i32::from(precision)) * 1e-9;
42 if (quantity.as_f64() - size).abs() > tolerance {
43 anyhow::bail!(QuantityPrecisionError { size, precision });
44 }
45
46 Ok(quantity)
47}
48
49pub(crate) fn generate_ib_trade_id(ts_event: UnixNanos, price: f64, size: f64) -> TradeId {
51 let ts_secs = ts_event.as_i64() / 1_000_000_000;
52 TradeId::new(format!("{ts_secs}-{price}-{size}"))
53}
54
55#[allow(clippy::too_many_arguments)]
64pub fn parse_quote_tick(
65 instrument_id: InstrumentId,
66 bid_price: Option<f64>,
67 ask_price: Option<f64>,
68 bid_size: Option<f64>,
69 ask_size: Option<f64>,
70 price_precision: u8,
71 size_precision: u8,
72 ts_event: UnixNanos,
73 ts_init: UnixNanos,
74) -> anyhow::Result<QuoteTick> {
75 let bid = bid_price.map(|p| Price::new(p, price_precision));
76 let ask = ask_price.map(|p| Price::new(p, price_precision));
77 let bid_qty = bid_size
78 .map(|s| checked_quantity(s, size_precision))
79 .transpose()?;
80 let ask_qty = ask_size
81 .map(|s| checked_quantity(s, size_precision))
82 .transpose()?;
83
84 Ok(QuoteTick::new(
85 instrument_id,
86 bid.unwrap_or_else(|| Price::zero(price_precision)),
87 ask.unwrap_or_else(|| Price::zero(price_precision)),
88 bid_qty.unwrap_or_else(|| Quantity::zero(size_precision)),
89 ask_qty.unwrap_or_else(|| Quantity::zero(size_precision)),
90 ts_event,
91 ts_init,
92 ))
93}
94
95#[allow(clippy::too_many_arguments)]
101pub fn parse_trade_tick(
102 instrument_id: InstrumentId,
103 price: f64,
104 size: f64,
105 price_precision: u8,
106 size_precision: u8,
107 ts_event: UnixNanos,
108 ts_init: UnixNanos,
109 trade_id: Option<TradeId>,
110) -> anyhow::Result<TradeTick> {
111 let trade_price = Price::new_checked(price, price_precision)
112 .map_err(|e| anyhow::anyhow!("invalid trade price={price}: {e}"))?;
113 let trade_size = checked_quantity(size, size_precision)?;
114 let aggressor_side = AggressorSide::NoAggressor; let trade_id = trade_id.unwrap_or_else(|| generate_ib_trade_id(ts_event, price, size));
116
117 TradeTick::new_checked(
118 instrument_id,
119 trade_price,
120 trade_size,
121 aggressor_side,
122 trade_id,
123 ts_event,
124 ts_init,
125 )
126 .map_err(|e| anyhow::anyhow!("invalid trade tick: {e}"))
127}
128
129pub(super) fn log_tick_parse_error(kind: &str, instrument_id: InstrumentId, e: &anyhow::Error) {
130 if e.is::<QuantityPrecisionError>() {
131 tracing::debug!("Dropping IB {kind} tick for {instrument_id}: {e}");
132 } else {
133 tracing::warn!("Failed to parse IB {kind} tick for {instrument_id}: {e}");
134 }
135}
136
137pub fn parse_index_price(
143 instrument_id: InstrumentId,
144 price: f64,
145 price_precision: u8,
146 price_magnifier: i32,
147 ts_event: UnixNanos,
148 ts_init: UnixNanos,
149) -> anyhow::Result<IndexPriceUpdate> {
150 let converted_price = if price_magnifier > 0 {
151 price / price_magnifier as f64
152 } else {
153 price
154 };
155
156 Ok(IndexPriceUpdate::new(
157 instrument_id,
158 Price::new(converted_price, price_precision),
159 ts_event,
160 ts_init,
161 ))
162}
163
164#[must_use]
166pub fn parse_option_computation_to_option_greeks(
167 instrument_id: InstrumentId,
168 computation: &OptionComputation,
169 ts_event: UnixNanos,
170 ts_init: UnixNanos,
171) -> Option<OptionGreeks> {
172 match computation.field {
173 TickType::ModelOption | TickType::DelayedModelOption => Some(OptionGreeks {
174 instrument_id,
175 greeks: OptionGreekValues {
176 delta: computation.delta.unwrap_or_default(),
177 gamma: computation.gamma.unwrap_or_default(),
178 vega: computation.vega.unwrap_or_default(),
179 theta: computation.theta.unwrap_or_default(),
180 rho: 0.0, },
182 convention: GreeksConvention::BlackScholes,
183 mark_iv: computation.implied_volatility,
184 bid_iv: None,
185 ask_iv: None,
186 underlying_price: computation.underlying_price,
187 open_interest: None,
188 ts_event,
189 ts_init,
190 }),
191 _ => None,
192 }
193}
194
195#[must_use]
197pub fn parse_option_open_interest(tick_type: &TickType, value: f64) -> Option<f64> {
198 match tick_type {
199 TickType::OpenInterest
200 | TickType::OptionCallOpenInterest
201 | TickType::OptionPutOpenInterest => Some(value),
202 _ => None,
203 }
204}
205
206#[allow(clippy::too_many_arguments)]
212pub fn parse_realtime_bar(
213 bar_type: BarType,
214 open: f64,
215 high: f64,
216 low: f64,
217 close: f64,
218 volume: f64,
219 price_precision: u8,
220 size_precision: u8,
221 ts_event: UnixNanos,
222 ts_init: UnixNanos,
223) -> anyhow::Result<Bar> {
224 let open_price = Price::new(open, price_precision);
225 let high_price = Price::new(high, price_precision);
226 let low_price = Price::new(low, price_precision);
227 let close_price = Price::new(close, price_precision);
228 let bar_volume = Quantity::new(volume, size_precision);
229
230 Ok(Bar::new(
231 bar_type,
232 open_price,
233 high_price,
234 low_price,
235 close_price,
236 bar_volume,
237 ts_event,
238 ts_init,
239 ))
240}
241
242#[must_use]
244pub fn parse_market_depth_operation(operation: i32) -> Option<BookAction> {
245 match operation {
246 0 => Some(BookAction::Add),
247 1 => Some(BookAction::Update),
248 2 => Some(BookAction::Delete),
249 _ => None,
250 }
251}
252
253#[cfg(test)]
256mod tests {
257 use ibapi::contracts::{OptionComputation, tick_types::TickType};
258 use nautilus_core::UnixNanos;
259 use nautilus_model::{
260 data::{BarSpecification, BarType},
261 enums::{AggregationSource, BarAggregation, PriceType},
262 identifiers::{InstrumentId, Symbol, Venue},
263 };
264 use rstest::rstest;
265
266 use super::*;
267
268 fn create_test_instrument_id() -> InstrumentId {
269 InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
270 }
271
272 #[rstest]
273 fn test_parse_quote_tick_with_all_fields() {
274 let instrument_id = create_test_instrument_id();
275 let result = parse_quote_tick(
276 instrument_id,
277 Some(150.25),
278 Some(150.30),
279 Some(100.0),
280 Some(200.0),
281 2,
282 0,
283 UnixNanos::new(0),
284 UnixNanos::new(0),
285 );
286 assert!(result.is_ok());
287 let quote = result.unwrap();
288 assert_eq!(quote.bid_price.as_f64(), 150.25);
289 assert_eq!(quote.ask_price.as_f64(), 150.30);
290 assert_eq!(quote.bid_size.as_f64(), 100.0);
291 assert_eq!(quote.ask_size.as_f64(), 200.0);
292 }
293
294 #[rstest]
295 fn test_parse_quote_tick_with_partial_fields() {
296 let instrument_id = create_test_instrument_id();
297 let result = parse_quote_tick(
298 instrument_id,
299 Some(150.25),
300 None,
301 Some(100.0),
302 None,
303 2,
304 0,
305 UnixNanos::new(0),
306 UnixNanos::new(0),
307 );
308 assert!(result.is_ok());
309 let quote = result.unwrap();
310 assert_eq!(quote.bid_price.as_f64(), 150.25);
311 assert_eq!(quote.ask_price.as_f64(), 0.0); assert_eq!(quote.bid_size.as_f64(), 100.0);
313 assert_eq!(quote.ask_size.as_f64(), 0.0); }
315
316 #[rstest]
317 fn test_parse_quote_tick_with_no_fields() {
318 let instrument_id = create_test_instrument_id();
319 let result = parse_quote_tick(
320 instrument_id,
321 None,
322 None,
323 None,
324 None,
325 2,
326 0,
327 UnixNanos::new(0),
328 UnixNanos::new(0),
329 );
330 assert!(result.is_ok());
331 let quote = result.unwrap();
332 assert_eq!(quote.bid_price.as_f64(), 0.0);
333 assert_eq!(quote.ask_price.as_f64(), 0.0);
334 }
335
336 #[rstest]
337 fn test_parse_trade_tick_with_trade_id() {
338 let instrument_id = create_test_instrument_id();
339 let trade_id = TradeId::from("TRADE-001");
340 let result = parse_trade_tick(
341 instrument_id,
342 150.25,
343 100.0,
344 2,
345 0,
346 UnixNanos::new(0),
347 UnixNanos::new(0),
348 Some(trade_id),
349 );
350 assert!(result.is_ok());
351 let trade = result.unwrap();
352 assert_eq!(trade.price.as_f64(), 150.25);
353 assert_eq!(trade.size.as_f64(), 100.0);
354 assert_eq!(trade.trade_id, trade_id);
355 }
356
357 #[rstest]
358 fn test_parse_trade_tick_without_trade_id() {
359 let instrument_id = create_test_instrument_id();
360 let result = parse_trade_tick(
361 instrument_id,
362 150.25,
363 100.0,
364 2,
365 0,
366 UnixNanos::new(1000),
367 UnixNanos::new(1000),
368 None,
369 );
370 assert!(result.is_ok());
371 let trade = result.unwrap();
372 assert_eq!(trade.price.as_f64(), 150.25);
373 assert_eq!(trade.size.as_f64(), 100.0);
374 assert!(!trade.trade_id.to_string().is_empty());
376 }
377
378 #[rstest]
379 fn test_parse_trade_tick_rejects_unrepresentable_size() {
380 let instrument_id = create_test_instrument_id();
381 let result = parse_trade_tick(
382 instrument_id,
383 150.25,
384 0.5,
385 2,
386 0,
387 UnixNanos::new(1000),
388 UnixNanos::new(1000),
389 None,
390 );
391
392 let e = result.unwrap_err();
393 assert_eq!(
394 e.downcast_ref::<QuantityPrecisionError>(),
395 Some(&QuantityPrecisionError {
396 size: 0.5,
397 precision: 0
398 })
399 );
400 }
401
402 #[rstest]
403 #[case(0.63462, 0)]
404 #[case(1.025, 2)]
405 fn test_quantity_precision_error_preserves_size(#[case] size: f64, #[case] precision: u8) {
406 let e = checked_quantity(size, precision).unwrap_err();
407 assert_eq!(
408 e.downcast_ref::<QuantityPrecisionError>(),
409 Some(&QuantityPrecisionError { size, precision })
410 );
411 }
412
413 #[rstest]
414 #[case(-1.0)]
415 #[case(f64::NAN)]
416 #[case(f64::INFINITY)]
417 fn test_invalid_quantities_are_not_expected_precision_drops(#[case] size: f64) {
418 let e = checked_quantity(size, 0).unwrap_err();
419 assert!(!e.is::<QuantityPrecisionError>());
420 }
421
422 #[rstest]
423 fn test_parse_trade_tick_rejects_zero_size() {
424 let result = parse_trade_tick(
425 create_test_instrument_id(),
426 150.25,
427 0.0,
428 2,
429 0,
430 UnixNanos::new(1000),
431 UnixNanos::new(1000),
432 None,
433 );
434
435 assert_eq!(
436 result.unwrap_err().to_string(),
437 "invalid trade tick: invalid `Quantity` for 'size' not positive, was 0",
438 );
439 }
440
441 #[rstest]
442 fn test_parse_quote_tick_rejects_unrepresentable_size() {
443 let instrument_id = create_test_instrument_id();
444 let result = parse_quote_tick(
445 instrument_id,
446 Some(150.25),
447 Some(150.30),
448 Some(0.5),
449 Some(200.0),
450 2,
451 0,
452 UnixNanos::new(0),
453 UnixNanos::new(0),
454 );
455
456 assert!(result.is_err());
457 }
458
459 #[rstest]
460 fn test_parse_index_price_with_price_magnifier() {
461 let instrument_id = create_test_instrument_id();
462 let result = parse_index_price(
463 instrument_id,
464 452525.0,
465 2,
466 100,
467 UnixNanos::new(0),
468 UnixNanos::new(0),
469 );
470 assert!(result.is_ok());
471 let index_price = result.unwrap();
472 assert_eq!(index_price.value.as_f64(), 4525.25);
473 }
474
475 #[rstest]
476 fn test_parse_index_price_without_price_magnifier() {
477 let instrument_id = create_test_instrument_id();
478 let result = parse_index_price(
479 instrument_id,
480 4525.25,
481 2,
482 1,
483 UnixNanos::new(0),
484 UnixNanos::new(0),
485 );
486 assert!(result.is_ok());
487 let index_price = result.unwrap();
488 assert_eq!(index_price.value.as_f64(), 4525.25);
489 }
490
491 #[rstest]
492 fn test_parse_realtime_bar() {
493 let instrument_id = create_test_instrument_id();
494
495 let bar_type = BarType::new(
496 instrument_id,
497 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
498 AggregationSource::External,
499 );
500 let result = parse_realtime_bar(
501 bar_type,
502 150.0,
503 151.0,
504 149.0,
505 150.5,
506 1000.0,
507 2,
508 0,
509 UnixNanos::new(0),
510 UnixNanos::new(0),
511 );
512 assert!(result.is_ok());
513 let bar = result.unwrap();
514 assert_eq!(bar.open.as_f64(), 150.0);
515 assert_eq!(bar.high.as_f64(), 151.0);
516 assert_eq!(bar.low.as_f64(), 149.0);
517 assert_eq!(bar.close.as_f64(), 150.5);
518 assert_eq!(bar.volume.as_f64(), 1000.0);
519 }
520
521 #[rstest]
522 fn test_parse_market_depth_operation_insert() {
523 let action = parse_market_depth_operation(0);
524 assert_eq!(action, Some(BookAction::Add));
525 }
526
527 #[rstest]
528 fn test_parse_market_depth_operation_update() {
529 let action = parse_market_depth_operation(1);
530 assert_eq!(action, Some(BookAction::Update));
531 }
532
533 #[rstest]
534 fn test_parse_market_depth_operation_delete() {
535 let action = parse_market_depth_operation(2);
536 assert_eq!(action, Some(BookAction::Delete));
537 }
538
539 #[rstest]
540 fn test_parse_market_depth_operation_unknown() {
541 let action = parse_market_depth_operation(99);
542 assert_eq!(action, None);
543 }
544
545 #[rstest]
546 fn test_parse_quote_tick_precision() {
547 let instrument_id = create_test_instrument_id();
548 let result = parse_quote_tick(
549 instrument_id,
550 Some(150.255),
551 Some(150.305),
552 Some(100.0),
553 Some(200.0),
554 2, 0, UnixNanos::new(0),
557 UnixNanos::new(0),
558 );
559 assert!(result.is_ok());
560 let quote = result.unwrap();
561 assert_eq!(quote.bid_price.as_f64(), 150.26); assert_eq!(quote.ask_price.as_f64(), 150.31); }
565
566 #[rstest]
567 fn test_parse_option_computation_to_option_greeks_from_model_tick() {
568 let instrument_id = create_test_instrument_id();
569
570 let greeks = parse_option_computation_to_option_greeks(
571 instrument_id,
572 &OptionComputation {
573 field: TickType::ModelOption,
574 implied_volatility: Some(0.25),
575 delta: Some(0.55),
576 gamma: Some(0.02),
577 vega: Some(0.15),
578 theta: Some(-0.05),
579 underlying_price: Some(155.0),
580 ..Default::default()
581 },
582 UnixNanos::new(10),
583 UnixNanos::new(11),
584 )
585 .unwrap();
586
587 assert_eq!(greeks.instrument_id, instrument_id);
588 assert_eq!(greeks.delta, 0.55);
589 assert_eq!(greeks.gamma, 0.02);
590 assert_eq!(greeks.vega, 0.15);
591 assert_eq!(greeks.theta, -0.05);
592 assert_eq!(greeks.rho, 0.0);
593 assert_eq!(greeks.mark_iv, Some(0.25));
594 assert_eq!(greeks.underlying_price, Some(155.0));
595 assert_eq!(greeks.open_interest, None);
596 assert_eq!(greeks.ts_event, UnixNanos::new(10));
597 assert_eq!(greeks.ts_init, UnixNanos::new(11));
598 }
599
600 #[rstest]
601 fn test_parse_option_computation_to_option_greeks_ignores_non_model_tick() {
602 let instrument_id = create_test_instrument_id();
603
604 let greeks = parse_option_computation_to_option_greeks(
605 instrument_id,
606 &OptionComputation {
607 field: TickType::BidOption,
608 implied_volatility: Some(0.24),
609 ..Default::default()
610 },
611 UnixNanos::new(10),
612 UnixNanos::new(11),
613 );
614
615 assert!(greeks.is_none());
616 }
617
618 #[rstest]
619 fn test_parse_option_open_interest_supports_option_interest_tick_types() {
620 assert_eq!(
621 parse_option_open_interest(&TickType::OptionCallOpenInterest, 1234.0),
622 Some(1234.0)
623 );
624 assert_eq!(
625 parse_option_open_interest(&TickType::OptionPutOpenInterest, 5678.0),
626 Some(5678.0)
627 );
628 assert_eq!(
629 parse_option_open_interest(&TickType::OpenInterest, 42.0),
630 Some(42.0)
631 );
632 assert_eq!(parse_option_open_interest(&TickType::Bid, 42.0), None);
633 }
634}