Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions crates/model/src/orderbook/binary_book_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// -------------------------------------------------------------------------------------------------
// Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
// https://nautechsystems.io
//
// Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// -------------------------------------------------------------------------------------------------

//! Binary market book views.

use ahash::AHashSet;
use indexmap::IndexMap;
use rust_decimal::Decimal;

use super::{book::OrderBook, own::OwnOrderBook};
use crate::{
data::BookOrder,
enums::{OrderSide, OrderStatus},
types::{Price, Quantity},
};

/// A filtered book view for binary markets, including synthetic orders.
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
)]
pub struct BinaryMarketBookView {
pub book: OrderBook,
}

impl BinaryMarketBookView {
/// Creates a new [`BinaryMarketBookView`] by filtering bids/asks and rebuilding a book.
/// # Panics
///
/// Panics if Price::from_decimal or Quantity::from_decimal fails when reconstructing orders.
#[must_use]
pub fn new(
book: OrderBook,
own_book: OwnOrderBook,
own_synthetic_book: OwnOrderBook,
depth: Option<usize>,
status: Option<AHashSet<OrderStatus>>,
accepted_buffer_ns: Option<u64>,
now: Option<u64>,
) -> Self {
let mut bids_map = book
.bids(depth)
.map(|level| (level.price.value.as_decimal(), level.size_decimal()))
.collect::<IndexMap<Decimal, Decimal>>();

filter_quantities(
&mut bids_map,
own_book.bid_quantity(status.clone(), None, None, accepted_buffer_ns, now),
);

let synthetic_as_bids = own_synthetic_book
.ask_quantity(status.clone(), None, None, accepted_buffer_ns, now)
.into_iter()
.map(|(price, quantity)| (Decimal::ONE - price, quantity))
.collect::<IndexMap<Decimal, Decimal>>();

filter_quantities(&mut bids_map, synthetic_as_bids);

let mut asks_map = book
.asks(depth)
.map(|level| (level.price.value.as_decimal(), level.size_decimal()))
.collect::<IndexMap<Decimal, Decimal>>();

filter_quantities(
&mut asks_map,
own_book.ask_quantity(status.clone(), None, None, accepted_buffer_ns, now),
);

let synthetic_as_asks = own_synthetic_book
.bid_quantity(status, None, None, accepted_buffer_ns, now)
.into_iter()
.map(|(price, quantity)| (Decimal::ONE - price, quantity))
.collect::<IndexMap<Decimal, Decimal>>();

filter_quantities(&mut asks_map, synthetic_as_asks);

let mut filtered_book = OrderBook::new(book.instrument_id, book.book_type);
let sequence = book.sequence;
let ts_event = book.ts_last;

let mut order_id = 1_u64;
for (price, quantity) in bids_map {
if quantity <= Decimal::ZERO {
continue;
}

let order = BookOrder::new(
OrderSide::Buy,
Price::from_decimal(price).expect("Invalid bid price for BinaryMarketBookView"),
Quantity::from_decimal(quantity)
.expect("Invalid bid quantity for BinaryMarketBookView"),
order_id,
);
order_id += 1;
filtered_book.add(order, 0, sequence, ts_event);
}

for (price, quantity) in asks_map {
if quantity <= Decimal::ZERO {
continue;
}

let order = BookOrder::new(
OrderSide::Sell,
Price::from_decimal(price).expect("Invalid ask price for BinaryMarketBookView"),
Quantity::from_decimal(quantity)
.expect("Invalid ask quantity for BinaryMarketBookView"),
order_id,
);
order_id += 1;
filtered_book.add(order, 0, sequence, ts_event);
}

Self {
book: filtered_book,
}
}
}

fn filter_quantities(
public_map: &mut IndexMap<Decimal, Decimal>,
own_map: IndexMap<Decimal, Decimal>,
) {
for (price, own_size) in own_map {
if let Some(public_size) = public_map.get_mut(&price) {
*public_size = (*public_size - own_size).max(Decimal::ZERO);

if *public_size == Decimal::ZERO {
public_map.shift_remove(&price);
}
}
}
}
2 changes: 2 additions & 0 deletions crates/model/src/orderbook/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
pub mod aggregation;
pub mod analysis;
pub mod binary_book_view;
pub mod book;
pub mod display;
pub mod error;
Expand All @@ -29,6 +30,7 @@ mod tests;

// Re-exports
pub use crate::orderbook::{
binary_book_view::BinaryMarketBookView,
book::OrderBook,
error::{BookIntegrityError, InvalidBookOperation},
ladder::BookPrice,
Expand Down
3 changes: 2 additions & 1 deletion crates/model/src/orderbook/own.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ impl Display for OwnBookOrder {
}
}

#[derive(Debug)]
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
Expand Down Expand Up @@ -573,6 +573,7 @@ where
}

/// Represents a ladder of price levels for one side of an order book.
#[derive(Clone)]
pub(crate) struct OwnBookLadder {
pub side: OrderSideSpecified,
pub levels: BTreeMap<BookPrice, OwnBookLevel>,
Expand Down
157 changes: 156 additions & 1 deletion crates/model/src/orderbook/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use crate::{
orderbook::{
BookIntegrityError, BookPrice, OrderBook, OwnBookOrder,
analysis::book_check_integrity,
binary_book_view::BinaryMarketBookView,
own::{OwnBookLadder, OwnBookLevel, OwnOrderBook},
},
stubs::TestDefault,
Expand Down Expand Up @@ -1619,6 +1620,161 @@ fn test_book_filtered_with_own_orders_different_level() {
assert_eq!(asks_filtered.get(&dec!(101.00)), Some(&dec!(100)));
}

#[rstest]
fn test_book_filtered_with_synthetic_orders() {
let instrument_yes_id = InstrumentId::from("YES.XNAS");
let instrument_no_id = InstrumentId::from("NO.XNAS");
let mut book = OrderBook::new(instrument_yes_id, BookType::L2_MBP);
let mut synthetic_book = OwnOrderBook::new(instrument_no_id);
let own_book = OwnOrderBook::new(instrument_yes_id);

// Public book levels
let bid_order = BookOrder::new(OrderSide::Buy, Price::from("0.40"), Quantity::from(100), 1);
let ask_order = BookOrder::new(OrderSide::Sell, Price::from("0.60"), Quantity::from(100), 2);

book.add(bid_order, 0, 1, 1.into());
book.add(ask_order, 0, 2, 2.into());

// Synthetic orders: ask at 0.60 -> bid at 0.40, bid at 0.40 -> ask at 0.60
let synthetic_ask_order = OwnBookOrder::new(
TraderId::test_default(),
ClientOrderId::from("SYN-ASK-1"),
Some(VenueOrderId::from("1")),
OrderSideSpecified::Sell,
Price::from("0.60"),
Quantity::from(30),
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Accepted,
1.into(),
1.into(),
1.into(),
1.into(),
);

let synthetic_bid_order = OwnBookOrder::new(
TraderId::test_default(),
ClientOrderId::from("SYN-BID-1"),
Some(VenueOrderId::from("2")),
OrderSideSpecified::Buy,
Price::from("0.40"),
Quantity::from(20),
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Accepted,
2.into(),
2.into(),
2.into(),
2.into(),
);

synthetic_book.add(synthetic_ask_order);
synthetic_book.add(synthetic_bid_order);

let view =
BinaryMarketBookView::new(book, own_book, synthetic_book, Some(10), None, None, None);
let bids_filtered = view.book.bids_as_map(None);
let asks_filtered = view.book.asks_as_map(None);

assert_eq!(bids_filtered.get(&dec!(0.40)), Some(&dec!(70))); // 100 - 30
assert_eq!(asks_filtered.get(&dec!(0.60)), Some(&dec!(80))); // 100 - 20
}

#[rstest]
fn test_book_filtered_with_own_and_synthetic_orders() {
let instrument_id = InstrumentId::from("AAPL.XNAS");
let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
let mut own_book = OwnOrderBook::new(instrument_id);
let mut synthetic_book = OwnOrderBook::new(instrument_id);

// Public book levels
let bid_order = BookOrder::new(OrderSide::Buy, Price::from("0.40"), Quantity::from(100), 1);
let ask_order = BookOrder::new(OrderSide::Sell, Price::from("0.60"), Quantity::from(100), 2);

book.add(bid_order, 0, 1, 1.into());
book.add(ask_order, 0, 2, 2.into());

// Own orders
let own_bid_order = OwnBookOrder::new(
TraderId::test_default(),
ClientOrderId::from("OWN-BID-1"),
Some(VenueOrderId::from("1")),
OrderSideSpecified::Buy,
Price::from("0.40"),
Quantity::from(10),
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Accepted,
1.into(),
1.into(),
1.into(),
1.into(),
);

let own_ask_order = OwnBookOrder::new(
TraderId::test_default(),
ClientOrderId::from("OWN-ASK-1"),
Some(VenueOrderId::from("2")),
OrderSideSpecified::Sell,
Price::from("0.60"),
Quantity::from(5),
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Accepted,
2.into(),
2.into(),
2.into(),
2.into(),
);

own_book.add(own_bid_order);
own_book.add(own_ask_order);

// Synthetic orders: ask at 0.60 -> bid at 0.40, bid at 0.40 -> ask at 0.60
let synthetic_ask_order = OwnBookOrder::new(
TraderId::test_default(),
ClientOrderId::from("SYN-ASK-1"),
Some(VenueOrderId::from("3")),
OrderSideSpecified::Sell,
Price::from("0.60"),
Quantity::from(30),
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Accepted,
3.into(),
3.into(),
3.into(),
3.into(),
);

let synthetic_bid_order = OwnBookOrder::new(
TraderId::test_default(),
ClientOrderId::from("SYN-BID-1"),
Some(VenueOrderId::from("4")),
OrderSideSpecified::Buy,
Price::from("0.40"),
Quantity::from(20),
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Accepted,
4.into(),
4.into(),
4.into(),
4.into(),
);

synthetic_book.add(synthetic_ask_order);
synthetic_book.add(synthetic_bid_order);

let view =
BinaryMarketBookView::new(book, own_book, synthetic_book, Some(10), None, None, None);
let bids_filtered = view.book.bids_as_map(None);
let asks_filtered = view.book.asks_as_map(None);

assert_eq!(bids_filtered.get(&dec!(0.40)), Some(&dec!(60))); // 100 - 10 - 30
assert_eq!(asks_filtered.get(&dec!(0.60)), Some(&dec!(75))); // 100 - 5 - 20
}

#[rstest]
fn test_book_filtered_with_status_filter() {
let instrument_id = InstrumentId::from("AAPL.XNAS");
Expand Down Expand Up @@ -1726,7 +1882,6 @@ fn test_book_filtered_with_status_filter() {
);
let asks_filtered =
book.asks_filtered_as_map(None, Some(&own_book), Some(status_filter), None, None);

// Check that only ACCEPTED own orders are subtracted
assert_eq!(bids_filtered.get(&dec!(100.00)), Some(&dec!(70))); // 100 - 30 = 70
assert_eq!(asks_filtered.get(&dec!(101.00)), Some(&dec!(70))); // 100 - 30 = 70
Expand Down
1 change: 1 addition & 0 deletions crates/model/src/python/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ pub fn model(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<crate::instruments::SyntheticInstrument>()?;
// Order book
m.add_class::<crate::orderbook::book::OrderBook>()?;
m.add_class::<crate::orderbook::binary_book_view::BinaryMarketBookView>()?;
m.add_class::<crate::orderbook::level::BookLevel>()?;
m.add_function(wrap_pyfunction!(
crate::python::orderbook::book::py_update_book_with_quote_tick,
Expand Down
Loading
Loading