use std::{
cell::{Cell, RefCell},
collections::HashSet,
rc::Rc,
};
use async_trait::async_trait;
use indexmap::IndexSet;
use nautilus_common::{
cache::Cache,
clients::ExecutionClient,
clock::{Clock, TestClock},
live::dst,
messages::{
ExecutionReport,
execution::{
BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateOrderStatusReport,
GenerateOrderStatusReports, GeneratePositionStatusReports, ModifyOrder, QueryAccount,
QueryOrder, SubmitOrder, SubmitOrderList, TradingCommand,
},
},
msgbus::{
self, MessagingSwitchboard,
stubs::{
TypedMessageSavingHandler, get_any_saving_handler, get_typed_message_saving_handler,
},
switchboard,
},
};
use nautilus_core::{UUID4, UnixNanos};
use nautilus_execution::{
engine::ExecutionEngine,
reconciliation::{
create_position_reconciliation_venue_order_id, process_mass_status_for_reconciliation,
},
};
use nautilus_live::manager::{ExecutionManager, ExecutionManagerConfig};
use nautilus_model::{
accounts::{AccountAny, MarginAccount},
enums::{
AccountType, ContingencyType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType,
PositionSideSpecified, TimeInForce, TriggerType,
},
events::{
OrderEventAny, OrderFilled,
account::state::AccountState,
order::spec::{OrderAcceptedSpec, OrderPendingCancelSpec, OrderPendingUpdateSpec},
},
identifiers::{
AccountId, ClientId, ClientOrderId, ExecAlgorithmId, InstrumentId, PositionId, StrategyId,
TradeId, TraderId, Venue, VenueOrderId,
},
instruments::{
Instrument, InstrumentAny,
stubs::{crypto_perpetual_ethusdt, currency_pair_btcusdt, xbtusd_bitmex},
},
orders::{
Order, OrderAny, OrderTestBuilder,
stubs::{OrderFilledTestBuilder, TestOrderEventStubs},
},
position::Position,
reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
};
use rstest::rstest;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
#[cfg(all(feature = "simulation", madsim))]
async fn advance_clock(d: dst::time::Duration) {
madsim::time::advance(d);
madsim::task::yield_now().await;
}
#[cfg(not(all(feature = "simulation", madsim)))]
async fn advance_clock(d: dst::time::Duration) {
tokio::time::advance(d).await;
}
struct TestContext {
clock: Rc<RefCell<TestClock>>,
cache: Rc<RefCell<Cache>>,
manager: ExecutionManager,
exec_engine: Rc<RefCell<ExecutionEngine>>,
}
impl TestContext {
fn new() -> Self {
Self::with_config(ExecutionManagerConfig::default())
}
fn with_config(config: ExecutionManagerConfig) -> Self {
let clock = Rc::new(RefCell::new(TestClock::new()));
let cache = Rc::new(RefCell::new(Cache::default()));
let account_state = AccountState::new(
test_account_id(),
AccountType::Margin,
vec![AccountBalance::new(
Money::from("1000000 USDT"),
Money::from("0 USDT"),
Money::from("1000000 USDT"),
)],
vec![],
true,
UUID4::new(),
UnixNanos::default(),
UnixNanos::default(),
Some(Currency::USDT()),
);
let account = AccountAny::Margin(MarginAccount::new(account_state, true));
cache.borrow_mut().add_account(account).unwrap();
let manager = ExecutionManager::new(clock.clone(), cache.clone(), config);
let mut engine = ExecutionEngine::new(clock.clone(), cache.clone(), None);
engine.register_oms_type(StrategyId::from("EXTERNAL"), OmsType::Hedging);
let exec_engine = Rc::new(RefCell::new(engine));
Self {
clock,
cache,
manager,
exec_engine,
}
}
fn advance_time(&self, delta_nanos: u64) {
let current = self.clock.borrow().get_time_ns();
self.clock
.borrow_mut()
.advance_time(UnixNanos::from(current.as_u64() + delta_nanos), true);
}
async fn advance_both(&self, d: dst::time::Duration) {
let delta_nanos = u64::try_from(d.as_nanos()).expect("test duration fits in u64 nanos");
self.advance_time(delta_nanos);
advance_clock(d).await;
}
fn add_instrument(&self, instrument: InstrumentAny) {
self.cache.borrow_mut().add_instrument(instrument).unwrap();
}
fn add_order(&self, order: OrderAny) {
self.cache
.borrow_mut()
.add_order(order, None, None, false)
.unwrap();
}
fn add_order_with_client_id(&self, order: OrderAny, client_id: ClientId) {
self.cache
.borrow_mut()
.add_order(order, None, Some(client_id), false)
.unwrap();
}
fn add_position(&self, position: &Position) {
self.cache
.borrow_mut()
.add_position(position, OmsType::Hedging)
.unwrap();
}
fn get_order(&self, client_order_id: &ClientOrderId) -> Option<OrderAny> {
self.cache
.borrow()
.order(client_order_id)
.map(|o| o.clone())
}
fn add_margin_account(&self, account_id: AccountId) {
let account_state = AccountState::new(
account_id,
AccountType::Margin,
vec![AccountBalance::new(
Money::from("1000000 USDT"),
Money::from("0 USDT"),
Money::from("1000000 USDT"),
)],
vec![],
true,
UUID4::new(),
UnixNanos::default(),
UnixNanos::default(),
Some(Currency::USDT()),
);
let account = AccountAny::Margin(MarginAccount::new(account_state, true));
self.cache.borrow_mut().add_account(account).unwrap();
}
}
fn test_instrument() -> InstrumentAny {
InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt())
}
fn test_instrument_id() -> InstrumentId {
crypto_perpetual_ethusdt().id()
}
fn test_instrument2() -> InstrumentAny {
InstrumentAny::CryptoPerpetual(xbtusd_bitmex())
}
fn test_instrument_id2() -> InstrumentId {
xbtusd_bitmex().id()
}
fn test_account_id() -> AccountId {
AccountId::from("BINANCE-001")
}
fn test_venue() -> Venue {
Venue::from("BINANCE")
}
fn test_client_id() -> ClientId {
ClientId::from("BINANCE")
}
fn create_limit_order(
client_order_id: &str,
instrument_id: InstrumentId,
side: OrderSide,
quantity: &str,
price: &str,
) -> OrderAny {
OrderTestBuilder::new(OrderType::Limit)
.client_order_id(ClientOrderId::from(client_order_id))
.instrument_id(instrument_id)
.side(side)
.quantity(Quantity::from(quantity))
.price(Price::from(price))
.build()
}
fn create_submitted_order(
client_order_id: &str,
instrument_id: InstrumentId,
side: OrderSide,
quantity: &str,
price: &str,
) -> OrderAny {
let mut order = create_limit_order(client_order_id, instrument_id, side, quantity, price);
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
order
}
fn create_order_status_report(
client_order_id: Option<ClientOrderId>,
venue_order_id: VenueOrderId,
instrument_id: InstrumentId,
status: OrderStatus,
quantity: Quantity,
filled_qty: Quantity,
) -> OrderStatusReport {
create_order_status_report_for_side(
client_order_id,
venue_order_id,
instrument_id,
OrderSide::Buy,
status,
quantity,
filled_qty,
)
}
fn create_order_status_report_for_side(
client_order_id: Option<ClientOrderId>,
venue_order_id: VenueOrderId,
instrument_id: InstrumentId,
order_side: OrderSide,
status: OrderStatus,
quantity: Quantity,
filled_qty: Quantity,
) -> OrderStatusReport {
OrderStatusReport::new(
test_account_id(),
instrument_id,
client_order_id,
venue_order_id,
order_side,
OrderType::Limit,
TimeInForce::Gtc,
status,
quantity,
filled_qty,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
)
.with_price(Price::from("3000.00"))
}
fn create_fill_report(
client_order_id: ClientOrderId,
venue_order_id: VenueOrderId,
instrument_id: InstrumentId,
trade_id: TradeId,
quantity: &str,
) -> FillReport {
FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
trade_id,
OrderSide::Buy,
Quantity::from(quantity),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
)
}
fn create_mass_status(
order_reports: Vec<OrderStatusReport>,
fill_reports: Vec<FillReport>,
) -> ExecutionMassStatus {
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
mass_status.add_order_reports(order_reports);
mass_status.add_fill_reports(fill_reports);
mass_status
}
#[rstest]
fn test_fill_deduplication_new_fill_not_processed() {
let ctx = TestContext::new();
let trade_id = TradeId::from("T-001");
assert!(!ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
trade_id
));
}
#[rstest]
fn test_fill_deduplication_tracks_processed_fill() {
let mut ctx = TestContext::new();
let trade_id = TradeId::from("T-001");
ctx.manager
.mark_fill_processed(test_account_id(), test_instrument_id(), trade_id);
assert!(ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
trade_id
));
assert!(!ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id2(),
trade_id
));
assert!(!ctx.manager.is_fill_recently_processed(
AccountId::from("BINANCE-002"),
test_instrument_id(),
trade_id
));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_fill_deduplication_prune_removes_expired() {
let mut ctx = TestContext::new();
let old_trade = TradeId::from("T-OLD");
let new_trade = TradeId::from("T-NEW");
ctx.manager
.mark_fill_processed(test_account_id(), test_instrument_id(), old_trade);
ctx.advance_both(dst::time::Duration::from_secs(120)).await;
ctx.manager
.mark_fill_processed(test_account_id(), test_instrument_id(), new_trade);
ctx.manager.prune_recent_fills_cache(60.0);
assert!(!ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
old_trade
));
assert!(ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
new_trade
));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_fill_deduplication_prune_uses_monotonic_ttl() {
let mut ctx = TestContext::new();
let trade_id = TradeId::from("T-MONOTONIC");
ctx.manager
.mark_fill_processed(test_account_id(), test_instrument_id(), trade_id);
ctx.advance_time(120_000_000_000);
ctx.manager.prune_recent_fills_cache(60.0);
assert!(ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
trade_id
));
advance_clock(dst::time::Duration::from_secs(61)).await;
ctx.manager.prune_recent_fills_cache(60.0);
assert!(!ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
trade_id
));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_prune_recent_fills_cache_keeps_all_on_overflow_ttl() {
let mut ctx = TestContext::new();
let trade_id = TradeId::from("T-KEEP");
ctx.manager
.mark_fill_processed(test_account_id(), test_instrument_id(), trade_id);
advance_clock(dst::time::Duration::from_nanos(1)).await;
ctx.manager.prune_recent_fills_cache(1.0e30);
assert!(
ctx.manager
.is_fill_recently_processed(test_account_id(), test_instrument_id(), trade_id),
"overflowing TTL must keep entries, not prune them",
);
ctx.manager.prune_recent_fills_cache(f64::INFINITY);
assert!(
ctx.manager
.is_fill_recently_processed(test_account_id(), test_instrument_id(), trade_id),
"infinite TTL must keep entries",
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_prune_recent_fills_cache_prunes_all_on_negative_or_nan_ttl() {
let mut ctx = TestContext::new();
ctx.manager.mark_fill_processed(
test_account_id(),
test_instrument_id(),
TradeId::from("T-NEG"),
);
advance_clock(dst::time::Duration::from_nanos(1)).await;
ctx.manager.prune_recent_fills_cache(-1.0);
assert!(!ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
TradeId::from("T-NEG"),
));
ctx.manager.mark_fill_processed(
test_account_id(),
test_instrument_id(),
TradeId::from("T-NAN"),
);
advance_clock(dst::time::Duration::from_nanos(1)).await;
ctx.manager.prune_recent_fills_cache(f64::NAN);
assert!(!ctx.manager.is_fill_recently_processed(
test_account_id(),
test_instrument_id(),
TradeId::from("T-NAN"),
));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_observe_order_report_clears_inflight_tracking() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 5,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-001", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
let order_report = create_order_status_report(
Some(client_order_id),
VenueOrderId::from("V-001"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from(0),
);
let report = ExecutionReport::Order(Box::new(order_report));
ctx.manager.observe_execution_report(&report);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(result.events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_observe_pending_order_report_keeps_inflight_tracking() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 3,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-PENDING");
ctx.add_instrument(test_instrument());
let order =
create_submitted_order("O-PENDING", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
let order_report = create_order_status_report(
Some(client_order_id),
VenueOrderId::from("V-001"),
instrument_id,
OrderStatus::PendingUpdate,
Quantity::from("1.0"),
Quantity::from(0),
);
let report = ExecutionReport::Order(Box::new(order_report));
ctx.manager.observe_execution_report(&report);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert_eq!(result.queries.len(), 1);
assert!(result.events.is_empty());
}
#[rstest]
fn test_observe_fill_report_does_not_mark_fill_processed() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let trade_id = TradeId::from("T-001");
ctx.add_instrument(test_instrument());
let fill_report = FillReport::new(
test_account_id(),
instrument_id,
VenueOrderId::from("V-001"),
trade_id,
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::new(0.0, Currency::USDT()),
LiquiditySide::Maker,
Some(ClientOrderId::from("O-001")),
None, UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None, );
let report = ExecutionReport::Fill(Box::new(fill_report));
ctx.manager.observe_execution_report(&report);
assert!(
!ctx.manager
.is_fill_recently_processed(test_account_id(), instrument_id, trade_id)
);
}
#[rstest]
fn test_observe_position_report_records_activity() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("1.0"),
UnixNanos::from(3_000_000),
UnixNanos::from(3_000_000),
None, None, Some(dec!(3000.0)),
);
let report = ExecutionReport::Position(Box::new(position_report));
ctx.manager.observe_execution_report(&report);
}
#[tokio::test]
async fn test_reconcile_mass_status_with_empty_reports() {
let mut ctx = TestContext::new();
let mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_creates_external_order_accepted() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None, VenueOrderId::from("V-EXT-001"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
let client_order_id = ClientOrderId::from("V-EXT-001");
let order = ctx.get_order(&client_order_id);
assert!(order.is_some());
}
#[rstest]
fn test_reconcile_order_status_report_publishes_external_order_initialized() {
let ctx = TestContext::new();
let instrument_id = test_instrument_id();
let strategy_id = StrategyId::from("EXT-PUBLISH-DIRECT");
ctx.add_instrument(test_instrument());
ctx.exec_engine
.borrow_mut()
.register_external_order_claims(strategy_id, &HashSet::from([instrument_id]))
.unwrap();
let topic = switchboard::get_event_order_topic(strategy_id);
let (handler, event_messages): (_, TypedMessageSavingHandler<OrderEventAny>) =
get_typed_message_saving_handler(None);
msgbus::subscribe_order_events(topic.into(), handler.clone(), None);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-PUBLISH-DIRECT"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
ctx.exec_engine
.borrow_mut()
.reconcile_order_status_report(&report);
msgbus::unsubscribe_order_events(topic.into(), &handler);
let messages = event_messages.get_messages();
assert_eq!(messages.len(), 2);
match &messages[0] {
OrderEventAny::Initialized(initialized) => {
assert_eq!(
initialized.client_order_id,
ClientOrderId::from("V-EXT-PUBLISH-DIRECT")
);
assert_eq!(initialized.strategy_id, strategy_id);
assert!(initialized.reconciliation);
}
event => panic!("Expected OrderInitialized event, was {event:?}"),
}
assert!(matches!(messages[1], OrderEventAny::Accepted(_)));
}
#[tokio::test]
async fn test_reconcile_mass_status_publishes_external_order_initialized() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let strategy_id = StrategyId::from("EXT-PUBLISH-MASS");
ctx.add_instrument(test_instrument());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
let topic = switchboard::get_event_order_topic(strategy_id);
let (handler, event_messages): (_, TypedMessageSavingHandler<OrderEventAny>) =
get_typed_message_saving_handler(None);
msgbus::subscribe_order_events(topic.into(), handler.clone(), None);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-PUBLISH-MASS"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
msgbus::unsubscribe_order_events(topic.into(), &handler);
let messages = event_messages.get_messages();
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert_eq!(messages.len(), 2);
match &messages[0] {
OrderEventAny::Initialized(initialized) => {
assert_eq!(
initialized.client_order_id,
ClientOrderId::from("V-EXT-PUBLISH-MASS")
);
assert_eq!(initialized.strategy_id, strategy_id);
assert!(initialized.reconciliation);
}
event => panic!("Expected OrderInitialized event, was {event:?}"),
}
assert!(matches!(messages[1], OrderEventAny::Accepted(_)));
}
#[rstest]
fn test_order_fill_replay_propagates_position_id_to_oto_contingent_order() {
let ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
let primary_id = ClientOrderId::from("O-OTO-PRIMARY");
let contingent_id = ClientOrderId::from("O-OTO-CONTINGENT");
let position_id = PositionId::from("P-OTO-001");
ctx.add_instrument(instrument.clone());
let mut primary_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(primary_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.price(Price::from("3000.00"))
.contingency_type(ContingencyType::Oto)
.linked_order_ids(vec![contingent_id])
.build();
apply_submitted_and_accepted(&mut primary_order, VenueOrderId::from("V-OTO-PRIMARY"));
let mut contingent_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(contingent_id)
.instrument_id(instrument_id)
.side(OrderSide::Sell)
.quantity(Quantity::from("1.0"))
.price(Price::from("3100.00"))
.build();
apply_submitted_and_accepted(
&mut contingent_order,
VenueOrderId::from("V-OTO-CONTINGENT"),
);
let strategy_id = primary_order.strategy_id();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Hedging);
ctx.add_order(primary_order.clone());
ctx.add_order(contingent_order);
let fill = TestOrderEventStubs::filled(
&primary_order,
&instrument,
Some(TradeId::from("T-OTO-001")),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("1.0")),
Some(LiquiditySide::Taker),
Some(Money::from("0 USDT")),
Some(UnixNanos::from(2_000_000)),
Some(test_account_id()),
);
ctx.exec_engine.borrow_mut().process(&fill);
let cache = ctx.cache.borrow();
let primary_after = cache.order(&primary_id).unwrap();
let contingent_after = cache.order(&contingent_id).unwrap();
assert_eq!(primary_after.position_id(), Some(position_id));
assert_eq!(contingent_after.position_id(), Some(position_id));
assert_eq!(cache.position_id(&contingent_id), Some(&position_id));
}
#[rstest]
fn test_order_fill_replay_propagates_position_id_to_exec_spawn_primary_order() {
let ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
let primary_id = ClientOrderId::from("O-SPAWN-PRIMARY");
let spawned_id = ClientOrderId::from("O-SPAWN-CHILD");
let position_id = PositionId::from("P-SPAWN-001");
ctx.add_instrument(instrument.clone());
let mut primary_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(primary_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(&mut primary_order, VenueOrderId::from("V-SPAWN-PRIMARY"));
let mut spawned_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(spawned_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.price(Price::from("3000.00"))
.exec_algorithm_id(ExecAlgorithmId::from("ALG-SPAWN"))
.exec_spawn_id(primary_id)
.build();
apply_submitted_and_accepted(&mut spawned_order, VenueOrderId::from("V-SPAWN-CHILD"));
let strategy_id = spawned_order.strategy_id();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Hedging);
ctx.add_order(primary_order);
ctx.add_order(spawned_order.clone());
let fill = TestOrderEventStubs::filled(
&spawned_order,
&instrument,
Some(TradeId::from("T-SPAWN-001")),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("1.0")),
Some(LiquiditySide::Taker),
Some(Money::from("0 USDT")),
Some(UnixNanos::from(2_000_000)),
Some(test_account_id()),
);
ctx.exec_engine.borrow_mut().process(&fill);
let cache = ctx.cache.borrow();
let primary_after = cache.order(&primary_id).unwrap();
let spawned_after = cache.order(&spawned_id).unwrap();
assert_eq!(primary_after.position_id(), Some(position_id));
assert_eq!(spawned_after.position_id(), Some(position_id));
assert_eq!(cache.position_id(&primary_id), Some(&position_id));
}
#[tokio::test]
async fn test_reconcile_mass_status_creates_external_order_canceled() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-002"),
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Canceled(_)));
}
#[tokio::test]
async fn test_external_order_canceled_with_partial_fill() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let venue_order_id = VenueOrderId::from("V-EXT-PARTIAL");
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0.5"),
)
.with_avg_px(dec!(3000.00));
mass_status.add_order_reports(vec![report]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-PARTIAL-001"),
OrderSide::Buy,
Quantity::from("0.5"),
Price::from("3000.00"),
Money::from("0.25 USDT"),
LiquiditySide::Maker,
None, None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 3);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
assert!(matches!(result.events[2], OrderEventAny::Canceled(_)));
if let OrderEventAny::Filled(filled) = &result.events[1] {
assert_eq!(filled.last_qty, Quantity::from("0.5"));
assert_eq!(filled.trade_id, TradeId::from("T-PARTIAL-001"));
}
let cache = ctx.cache.borrow();
let orders = cache.orders(None, None, None, None, None);
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.status(), OrderStatus::Canceled);
assert_eq!(order.filled_qty(), Quantity::from("0.5"));
}
#[rstest]
#[case(OrderStatus::Canceled)]
#[case(OrderStatus::Expired)]
#[tokio::test]
async fn test_external_terminal_order_with_incomplete_real_fills_infers_residual(
#[case] terminal_status: OrderStatus,
) {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let venue_order_id = VenueOrderId::from("V-EXT-TERMINAL-RESIDUAL");
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
terminal_status,
Quantity::from("2.0"),
Quantity::from("1.5"),
)
.with_avg_px(dec!(3000.00));
mass_status.add_order_reports(vec![report]);
let real_trade_id = TradeId::from("T-TERMINAL-RESIDUAL-001");
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
real_trade_id,
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 4);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(
(&result.events[3], terminal_status),
(OrderEventAny::Canceled(_), OrderStatus::Canceled)
| (OrderEventAny::Expired(_), OrderStatus::Expired),
));
let OrderEventAny::Filled(real_fill) = &result.events[1] else {
panic!("Expected real Filled event, was {:?}", result.events[1]);
};
let OrderEventAny::Filled(inferred_fill) = &result.events[2] else {
panic!("Expected inferred Filled event, was {:?}", result.events[2]);
};
assert_eq!(real_fill.trade_id, real_trade_id);
assert_eq!(real_fill.last_qty, Quantity::from("1.0"));
assert_ne!(inferred_fill.trade_id, real_trade_id);
assert_eq!(inferred_fill.last_qty, Quantity::from("0.5"));
let cache = ctx.cache.borrow();
let orders = cache.orders(None, None, None, None, None);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].status(), terminal_status);
assert_eq!(orders[0].filled_qty(), Quantity::from("1.5"));
}
#[tokio::test]
async fn test_cached_order_canceled_with_fills() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-CANCEL-FILL");
let venue_order_id = VenueOrderId::from("V-CANCEL-FILL");
ctx.add_instrument(test_instrument());
let mut order = create_submitted_order(
"O-CANCEL-FILL",
instrument_id,
OrderSide::Buy,
"2.0",
"3000.00",
);
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("2.0"),
Quantity::from("1.0"),
)
.with_avg_px(dec!(3000.00));
mass_status.add_order_reports(vec![report]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-CACHED-001"),
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Filled(_)));
assert!(matches!(result.events[1], OrderEventAny::Canceled(_)));
let cached_order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(cached_order.status(), OrderStatus::Canceled);
assert_eq!(cached_order.filled_qty(), Quantity::from("1.0"));
}
#[tokio::test]
async fn test_triggered_event_generated_before_canceled() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-TRIG-CANCEL");
let venue_order_id = VenueOrderId::from("V-TRIG-CANCEL");
ctx.add_instrument(test_instrument());
let mut order = OrderTestBuilder::new(OrderType::StopLimit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.price(Price::from("3000.00"))
.trigger_price(Price::from("3100.00"))
.trigger_type(TriggerType::LastPrice)
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let mut report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0"),
);
report.ts_triggered = Some(UnixNanos::from(500_000));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Triggered(_)));
assert!(matches!(result.events[1], OrderEventAny::Canceled(_)));
}
#[tokio::test]
async fn test_reconcile_mass_status_creates_external_order_filled() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-003"),
instrument_id,
OrderStatus::Filled,
Quantity::from("1.0"),
Quantity::from("1.0"),
)
.with_avg_px(dec!(3000.50));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled) = &result.events[1] {
assert_eq!(filled.last_qty, Quantity::from("1.0"));
assert!(filled.reconciliation);
}
}
#[tokio::test]
async fn test_external_order_filled_uses_real_fills() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let venue_order_id = VenueOrderId::from("V-EXT-FILLED");
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("2.0"),
Quantity::from("2.0"),
)
.with_avg_px(dec!(3000.00));
mass_status.add_order_reports(vec![report]);
let fill1 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-FILL-001"),
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("2999.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
let fill2 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-FILL-002"),
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3001.00"),
Money::from("0.50 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
);
mass_status.add_fill_reports(vec![fill1, fill2]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status.clone(), ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 3);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
assert!(matches!(result.events[2], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled1) = &result.events[1] {
assert_eq!(filled1.trade_id, TradeId::from("T-FILL-001"));
assert_eq!(filled1.last_qty, Quantity::from("1.0"));
assert_eq!(filled1.last_px, Price::from("2999.00"));
}
if let OrderEventAny::Filled(filled2) = &result.events[2] {
assert_eq!(filled2.trade_id, TradeId::from("T-FILL-002"));
assert_eq!(filled2.last_qty, Quantity::from("1.0"));
assert_eq!(filled2.last_px, Price::from("3001.00"));
}
{
let cache = ctx.cache.borrow();
let orders = cache.orders(None, None, None, None, None);
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(order.filled_qty(), Quantity::from("2.0"));
}
ctx.manager = ExecutionManager::new(
ctx.clock.clone(),
ctx.cache.clone(),
ExecutionManagerConfig::default(),
);
let replay = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
assert!(replay.events.is_empty());
assert_eq!(cache.orders(None, None, None, None, None).len(), 1);
assert_eq!(
cache.orders(None, None, None, None, None)[0].filled_qty(),
Quantity::from("2.0"),
);
}
#[tokio::test]
async fn test_external_order_filled_with_partial_fills_generates_inferred() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let venue_order_id = VenueOrderId::from("V-EXT-PARTIAL-INFER");
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("3.000"),
Quantity::from("3.000"),
)
.with_avg_px(dec!(3000.00));
mass_status.add_order_reports(vec![report]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-PARTIAL-001"),
OrderSide::Buy,
Quantity::from("2.000"),
Price::from("2999.00"),
Money::from("1.00 USDT"),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 3);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
assert!(matches!(result.events[2], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled1) = &result.events[1] {
assert_eq!(filled1.trade_id, TradeId::from("T-PARTIAL-001"));
assert_eq!(filled1.last_qty, Quantity::from("2.000"));
}
if let OrderEventAny::Filled(filled2) = &result.events[2] {
assert_ne!(
filled2.trade_id.as_str(),
"T-PARTIAL-001",
"Expected inferred trade ID (UUID), was known fill report trade ID"
);
assert_eq!(filled2.trade_id.as_str().len(), 36);
assert_eq!(filled2.last_qty, Quantity::from("1.000"));
}
let cache = ctx.cache.borrow();
let orders = cache.orders(None, None, None, None, None);
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.filled_qty(), Quantity::from("3.000"));
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_external_when_filtered() {
let config = ExecutionManagerConfig {
filter_unclaimed_external: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-001"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_synthetic_orders_bypass_filter_unclaimed_external() {
let config = ExecutionManagerConfig {
filter_unclaimed_external: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("S-abc123-def456"),
instrument_id,
OrderStatus::Filled,
Quantity::from("1.0"),
Quantity::from("1.0"),
)
.with_avg_px(dec!(100.0));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(!result.events.is_empty());
assert!(
result
.events
.iter()
.any(|e| matches!(e, OrderEventAny::Filled(_)))
);
if let OrderEventAny::Accepted(accepted) = &result.events[0] {
let order = ctx
.get_order(&accepted.client_order_id)
.expect("Order should exist");
let tags = order.tags().expect("Order should have tags");
assert!(
tags.contains(&ustr::Ustr::from("RECONCILIATION")),
"Synthetic order should have RECONCILIATION tag, was {tags:?}",
);
} else {
panic!("Expected Accepted event first, was {:?}", result.events[0]);
}
}
#[tokio::test]
async fn test_reconcile_mass_status_uses_claimed_strategy() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let strategy_id = StrategyId::from("MY-STRATEGY");
ctx.add_instrument(test_instrument());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-001"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
let client_order_id = ClientOrderId::from("V-EXT-001");
let order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(order.strategy_id(), strategy_id);
}
#[tokio::test]
async fn test_claim_external_orders_duplicate_fails_without_overwriting() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let strategy_id = StrategyId::from("MY-STRATEGY");
let duplicate_strategy_id = StrategyId::from("OTHER-STRATEGY");
ctx.add_instrument(test_instrument());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
let result = ctx
.manager
.claim_external_orders(instrument_id, duplicate_strategy_id);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("already exists for MY-STRATEGY")
);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-DUPLICATE"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
let client_order_id = ClientOrderId::from("V-EXT-DUPLICATE");
let order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(order.strategy_id(), strategy_id);
}
#[tokio::test]
async fn test_reconcile_mass_status_processes_fills_for_cached_order() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "2.0", "3000.00");
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Filled(_)));
}
#[tokio::test]
async fn test_reconcile_mass_status_deduplicates_fills() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let trade_id = TradeId::from("T-001");
ctx.add_instrument(test_instrument());
let order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "2.0", "3000.00");
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
trade_id,
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill.clone(), fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_canonical_duplicate_reconciliation_fill_is_committed() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-DUPLICATE-COMMIT");
let venue_order_id = VenueOrderId::from("V-DUPLICATE-COMMIT");
let retry_client_order_id = ClientOrderId::from("O-DUPLICATE-COMMIT-2");
let retry_venue_order_id = VenueOrderId::from("V-DUPLICATE-COMMIT-2");
let trade_id = TradeId::from("T-DUPLICATE-COMMIT");
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let mut order = create_accepted_order(
client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"2.0",
"3000.00",
venue_order_id,
);
let existing_fill = TestOrderEventStubs::filled(
&order,
&instrument,
Some(trade_id),
None,
Some(Price::from("3000.00")),
Some(Quantity::from("1.0")),
Some(LiquiditySide::Maker),
None,
None,
Some(test_account_id()),
);
order.apply(existing_fill).unwrap();
ctx.add_order(order);
let duplicate_report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("2.0"),
Quantity::from("2.0"),
);
let duplicate_fill = create_fill_report(
client_order_id,
venue_order_id,
instrument_id,
trade_id,
"1.0",
);
let duplicate = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(vec![duplicate_report], vec![duplicate_fill]),
ctx.exec_engine.clone(),
)
.await;
assert!(duplicate.events.is_empty());
ctx.add_order(create_accepted_order(
retry_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
retry_venue_order_id,
));
let retry_fill = create_fill_report(
retry_client_order_id,
retry_venue_order_id,
instrument_id,
trade_id,
"1.0",
);
let retry = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(vec![], vec![retry_fill]),
ctx.exec_engine.clone(),
)
.await;
assert!(retry.events.is_empty());
assert!(
ctx.get_order(&retry_client_order_id)
.unwrap()
.trade_ids()
.is_empty()
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_rejected_reconciliation_fill_is_retried() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-REJECT-RETRY");
let venue_order_id = VenueOrderId::from("V-REJECT-RETRY");
let retry_client_order_id = ClientOrderId::from("O-REJECT-RETRY-2");
let retry_venue_order_id = VenueOrderId::from("V-REJECT-RETRY-2");
let existing_trade_id = TradeId::from("T-REJECT-EXISTING");
let trade_id = TradeId::from("T-REJECT-RETRY");
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let mut order = create_accepted_order(
client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
venue_order_id,
);
let existing_fill = TestOrderEventStubs::filled(
&order,
&instrument,
Some(existing_trade_id),
None,
Some(Price::from("3000.00")),
Some(Quantity::from("1.0")),
Some(LiquiditySide::Maker),
None,
None,
Some(test_account_id()),
);
order.apply(existing_fill).unwrap();
ctx.add_order(order);
let rejected_report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("2.0"),
Quantity::from("2.0"),
);
let rejected_fill = create_fill_report(
client_order_id,
venue_order_id,
instrument_id,
trade_id,
"1.0",
);
let rejected = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(vec![rejected_report], vec![rejected_fill]),
ctx.exec_engine.clone(),
)
.await;
assert!(rejected.events.is_empty());
let rejected_order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(rejected_order.trade_ids(), vec![&existing_trade_id]);
ctx.add_order(create_accepted_order(
retry_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
retry_venue_order_id,
));
let retry_fill = create_fill_report(
retry_client_order_id,
retry_venue_order_id,
instrument_id,
trade_id,
"1.0",
);
let retry = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(vec![], vec![retry_fill]),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(retry.events.len(), 1);
assert!(matches!(retry.events[0], OrderEventAny::Filled(_)));
let retry_order = ctx.get_order(&retry_client_order_id).unwrap();
assert_eq!(retry_order.trade_ids(), vec![&trade_id]);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_reconciliation_fill_dispatch_rejection_does_not_commit() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-DISPATCH-RETRY");
let venue_order_id = VenueOrderId::from("V-DISPATCH-RETRY");
let trade_id = TradeId::from("T-DISPATCH-RETRY");
ctx.add_instrument(test_instrument());
ctx.add_order(create_limit_order(
client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
));
let fill = create_fill_report(
client_order_id,
venue_order_id,
instrument_id,
trade_id,
"1.0",
);
let rejected = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(vec![], vec![fill.clone()]),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(
rejected
.events
.iter()
.filter(|event| matches!(event, OrderEventAny::Filled(_)))
.count(),
1
);
assert!(
!ctx.get_order(&client_order_id)
.unwrap()
.trade_ids()
.contains(&&trade_id)
);
let order = ctx.get_order(&client_order_id).unwrap();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
let order = ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
ctx.cache.borrow_mut().update_order(&accepted).unwrap();
let retry = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(vec![], vec![fill]),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(
retry
.events
.iter()
.filter(|event| matches!(event, OrderEventAny::Filled(_)))
.count(),
1
);
assert!(
ctx.get_order(&client_order_id)
.unwrap()
.trade_ids()
.contains(&&trade_id)
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_applied_reconciliation_fill_is_committed() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let trade_id = TradeId::from("T-COMMITTED");
let first_client_order_id = ClientOrderId::from("O-COMMITTED-1");
let first_venue_order_id = VenueOrderId::from("V-COMMITTED-1");
let second_client_order_id = ClientOrderId::from("O-COMMITTED-2");
let second_venue_order_id = VenueOrderId::from("V-COMMITTED-2");
ctx.add_instrument(test_instrument());
ctx.add_order(create_accepted_order(
first_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
first_venue_order_id,
));
ctx.add_order(create_accepted_order(
second_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
second_venue_order_id,
));
let first = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![create_fill_report(
first_client_order_id,
first_venue_order_id,
instrument_id,
trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(first.events.len(), 1);
let duplicate = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![create_fill_report(
second_client_order_id,
second_venue_order_id,
instrument_id,
trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
assert!(duplicate.events.is_empty());
assert!(
ctx.get_order(&second_client_order_id)
.unwrap()
.trade_ids()
.is_empty()
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_same_cycle_cross_order_fill_is_queued_once() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let trade_id = TradeId::from("T-CROSS-ORDER");
let first_client_order_id = ClientOrderId::from("O-CROSS-ORDER-1");
let first_venue_order_id = VenueOrderId::from("V-CROSS-ORDER-1");
let second_client_order_id = ClientOrderId::from("O-CROSS-ORDER-2");
let second_venue_order_id = VenueOrderId::from("V-CROSS-ORDER-2");
ctx.add_instrument(test_instrument());
ctx.add_order(create_accepted_order(
first_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
first_venue_order_id,
));
ctx.add_order(create_accepted_order(
second_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
second_venue_order_id,
));
let result = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![
create_fill_report(
first_client_order_id,
first_venue_order_id,
instrument_id,
trade_id,
"1.0",
),
create_fill_report(
second_client_order_id,
second_venue_order_id,
instrument_id,
trade_id,
"1.0",
),
],
),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(
result
.events
.iter()
.filter(|event| matches!(event, OrderEventAny::Filled(_)))
.count(),
1
);
let applied_orders = [first_client_order_id, second_client_order_id]
.iter()
.filter(|client_order_id| {
ctx.get_order(client_order_id)
.unwrap()
.trade_ids()
.contains(&&trade_id)
})
.count();
assert_eq!(applied_orders, 1);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inferred_fill_is_not_committed_as_reported_fill() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let external_venue_order_id = VenueOrderId::from("V-INFERRED-SOURCE");
ctx.add_instrument(test_instrument());
let order_report = create_order_status_report(
None,
external_venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("2.0"),
Quantity::from("2.0"),
)
.with_avg_px(dec!(3000.0));
let real_trade_id = TradeId::from("T-INFERRED-SOURCE");
let source = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![order_report],
vec![create_fill_report(
ClientOrderId::from(external_venue_order_id.as_str()),
external_venue_order_id,
instrument_id,
real_trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
let inferred_trade_id = source
.events
.iter()
.find_map(|event| match event {
OrderEventAny::Filled(fill) if fill.trade_id != real_trade_id => Some(fill.trade_id),
_ => None,
})
.expect("expected inferred fill");
let client_order_id = ClientOrderId::from("O-INFERRED-REUSE");
let venue_order_id = VenueOrderId::from("V-INFERRED-REUSE");
ctx.add_order(create_accepted_order(
client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
venue_order_id,
));
let reused = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![create_fill_report(
client_order_id,
venue_order_id,
instrument_id,
inferred_trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(reused.events.len(), 1);
assert!(matches!(reused.events[0], OrderEventAny::Filled(_)));
}
#[rstest]
#[case(Some(0), 60, true)]
#[case(Some(2), 120, true)]
#[case(None, 86_400, false)]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_processed_fill_retention(
#[case] lookback_mins: Option<u64>,
#[case] horizon_secs: u64,
#[case] prunes_past_horizon: bool,
) {
let config = ExecutionManagerConfig {
lookback_mins,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let trade_id = TradeId::from("T-RETENTION");
let first_client_order_id = ClientOrderId::from("O-RETENTION-1");
let first_venue_order_id = VenueOrderId::from("V-RETENTION-1");
let second_client_order_id = ClientOrderId::from("O-RETENTION-2");
let second_venue_order_id = VenueOrderId::from("V-RETENTION-2");
ctx.add_instrument(test_instrument());
ctx.add_order(create_accepted_order(
first_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
first_venue_order_id,
));
ctx.add_order(create_accepted_order(
second_client_order_id.as_str(),
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
second_venue_order_id,
));
let first = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![create_fill_report(
first_client_order_id,
first_venue_order_id,
instrument_id,
trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(first.events.len(), 1);
ctx.advance_both(dst::time::Duration::from_secs(horizon_secs))
.await;
ctx.manager.prune_processed_fills();
let at_horizon = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![create_fill_report(
second_client_order_id,
second_venue_order_id,
instrument_id,
trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
assert!(at_horizon.events.is_empty());
ctx.advance_both(dst::time::Duration::from_nanos(1)).await;
ctx.manager.prune_processed_fills();
let past_horizon = ctx
.manager
.reconcile_execution_mass_status(
create_mass_status(
vec![],
vec![create_fill_report(
second_client_order_id,
second_venue_order_id,
instrument_id,
trade_id,
"1.0",
)],
),
ctx.exec_engine.clone(),
)
.await;
assert_eq!(past_horizon.events.len(), usize::from(prunes_past_horizon));
}
#[rstest]
#[case(OmsType::Netting, true, true, true)]
#[case(OmsType::Netting, true, false, true)]
#[case(OmsType::Netting, false, true, true)]
#[case(OmsType::Netting, false, false, true)]
#[case(OmsType::Hedging, true, true, true)]
#[case(OmsType::Hedging, true, false, true)]
#[case(OmsType::Hedging, false, true, true)]
#[case(OmsType::Hedging, false, false, true)]
#[case(OmsType::Netting, true, false, false)]
#[case(OmsType::Netting, false, false, false)]
#[case(OmsType::Hedging, true, false, false)]
#[case(OmsType::Hedging, false, false, false)]
#[tokio::test]
async fn test_retained_fill_projects_missing_order_without_reapplying(
#[case] oms_type: OmsType,
#[case] generate_missing_orders: bool,
#[case] include_fill_report: bool,
#[case] include_client_order_id: bool,
) {
let config = ExecutionManagerConfig {
generate_missing_orders,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let strategy_id = StrategyId::from("STRATEGY-001");
let client_order_id = ClientOrderId::from("O-RETAINED-001");
let venue_order_id = VenueOrderId::from("V-RETAINED-001");
let trade_id = TradeId::from("T-RETAINED-001");
let position_id = match oms_type {
OmsType::Hedging => PositionId::from("P-RETAINED-001"),
_ => PositionId::new(format!("{instrument_id}-{strategy_id}")),
};
ctx.add_instrument(instrument.clone());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, oms_type);
let mut restored_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.strategy_id(strategy_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.000"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(&mut restored_order, venue_order_id);
let restored_fill = TestOrderEventStubs::filled(
&restored_order,
&instrument,
Some(trade_id),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("1.000")),
Some(LiquiditySide::Maker),
Some(Money::from("0.50 USDT")),
Some(UnixNanos::from(1_000_000)),
Some(test_account_id()),
);
let position = Position::new(&instrument, restored_fill.into());
ctx.cache
.borrow_mut()
.add_position(&position, oms_type)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let mut order_report = create_order_status_report(
include_client_order_id.then_some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("1.000"),
Quantity::from("1.000"),
)
.with_avg_px(dec!(3000.0));
let venue_position_id = (oms_type == OmsType::Hedging).then_some(position_id);
if let Some(position_id) = venue_position_id {
order_report = order_report.with_venue_position_id(position_id);
}
let fill_report = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
trade_id,
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
venue_position_id,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("1.000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
venue_position_id,
Some(dec!(3000.00)),
);
mass_status.add_order_reports(vec![order_report]);
if include_fill_report {
mass_status.add_fill_reports(vec![fill_report]);
}
mass_status.add_position_reports(vec![position_report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let reconciled_order_id = if include_client_order_id {
client_order_id
} else {
ClientOrderId::from(venue_order_id.as_str())
};
let order = cache.order(&reconciled_order_id).unwrap();
let position = cache.position(&position_id).unwrap();
assert_eq!(cache.oms_type(&position_id), Some(oms_type));
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(order.filled_qty(), Quantity::from("1.000"));
assert_eq!(position.quantity, Quantity::from("1.000"));
assert_eq!(position.realized_pnl, Some(Money::from("-0.50 USDT")));
assert_eq!(position.commissions(), vec![Money::from("0.50 USDT")]);
assert_eq!(position.trade_ids.len(), 1);
assert!(position.trade_ids.contains(&trade_id));
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_inferred_delta_for_retained_order_applies_new_economics(
#[case] generate_missing_orders: bool,
) {
let config = ExecutionManagerConfig {
generate_missing_orders,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let strategy_id = StrategyId::from("STRATEGY-001");
let client_order_id = ClientOrderId::from("O-RETAINED-PARTIAL");
let venue_order_id = VenueOrderId::from("V-RETAINED-PARTIAL");
let known_trade_id = TradeId::from("T-RETAINED-PARTIAL");
let position_id = PositionId::new(format!("{instrument_id}-{strategy_id}"));
ctx.add_instrument(instrument.clone());
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Netting);
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.strategy_id(strategy_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("10.000"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(&mut order, venue_order_id);
let known_fill = TestOrderEventStubs::filled(
&order,
&instrument,
Some(known_trade_id),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("5.000")),
Some(LiquiditySide::Maker),
Some(Money::from("0.50 USDT")),
Some(UnixNanos::from(1_000_000)),
Some(test_account_id()),
);
order.apply(known_fill.clone()).unwrap();
let position = Position::new(&instrument, known_fill.into());
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_position(&position, OmsType::Netting)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("10.000"),
Quantity::from("10.000"),
)
.with_avg_px(dec!(3000.0));
mass_status.add_order_reports(vec![report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let order = cache.order(&client_order_id).unwrap();
let position = cache.position(&position_id).unwrap();
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(order.filled_qty(), Quantity::from("10.000"));
assert_eq!(position.quantity, Quantity::from("10.000"));
assert_eq!(position.realized_pnl, Some(Money::from("-0.50 USDT")));
assert_eq!(position.commissions(), vec![Money::from("0.50 USDT")]);
assert_eq!(position.trade_ids.len(), 2);
assert!(position.trade_ids.contains(&known_trade_id));
}
#[tokio::test]
async fn test_missing_venue_order_id_collision_is_scoped_by_instrument() {
let mut ctx = TestContext::new();
let retained_instrument = test_instrument();
let retained_instrument_id = retained_instrument.id();
let new_instrument = test_instrument2();
let new_instrument_id = new_instrument.id();
let strategy_id = StrategyId::from("STRATEGY-001");
let venue_order_id = VenueOrderId::from("V-COLLISION");
let retained_position_id = PositionId::new(format!("{retained_instrument_id}-{strategy_id}"));
ctx.add_instrument(retained_instrument.clone());
ctx.add_instrument(new_instrument);
ctx.manager
.claim_external_orders(new_instrument_id, strategy_id)
.unwrap();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Netting);
let mut retained_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(ClientOrderId::from("O-RETAINED-COLLISION"))
.strategy_id(strategy_id)
.instrument_id(retained_instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.000"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(&mut retained_order, venue_order_id);
let retained_fill = TestOrderEventStubs::filled(
&retained_order,
&retained_instrument,
Some(TradeId::from("T-RETAINED-COLLISION")),
Some(retained_position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("1.000")),
Some(LiquiditySide::Maker),
Some(Money::from("0.50 USDT")),
Some(UnixNanos::from(1_000_000)),
Some(test_account_id()),
);
let retained_position = Position::new(&retained_instrument, retained_fill.into());
ctx.cache
.borrow_mut()
.add_position(&retained_position, OmsType::Netting)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
new_instrument_id,
OrderStatus::Filled,
Quantity::from("1.000"),
Quantity::from("1.000"),
)
.with_avg_px(dec!(3000.0));
mass_status.add_order_reports(vec![report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let new_position_id = PositionId::new(format!("{new_instrument_id}-{strategy_id}"));
let cache = ctx.cache.borrow();
let order = cache
.order(&ClientOrderId::from(venue_order_id.as_str()))
.unwrap();
let position = cache.position(&new_position_id).unwrap();
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(position.quantity, Quantity::from("1.000"));
assert_eq!(position.trade_ids.len(), 1);
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_partially_known_fills_apply_only_new_economics(
#[case] generate_missing_orders: bool,
) {
let config = ExecutionManagerConfig {
generate_missing_orders,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let strategy_id = StrategyId::from("STRATEGY-001");
let client_order_id = ClientOrderId::from("O-RETAINED-002");
let venue_order_id = VenueOrderId::from("V-RETAINED-002");
let known_trade_id = TradeId::from("T-RETAINED-002-A");
let new_trade_id = TradeId::from("T-RETAINED-002-B");
let position_id = PositionId::new(format!("{instrument_id}-{strategy_id}"));
ctx.add_instrument(instrument.clone());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Netting);
let mut restored_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.strategy_id(strategy_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("2.000"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(&mut restored_order, venue_order_id);
let known_fill = TestOrderEventStubs::filled(
&restored_order,
&instrument,
Some(known_trade_id),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("2.000")),
Some(LiquiditySide::Maker),
Some(Money::from("1.00 USDT")),
Some(UnixNanos::from(1_000_000)),
Some(test_account_id()),
);
let position = Position::new(&instrument, known_fill.into());
ctx.cache
.borrow_mut()
.add_position(&position, OmsType::Netting)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let order_report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("3.000"),
Quantity::from("3.000"),
)
.with_avg_px(dec!(3033.333333));
let known_fill_report = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
known_trade_id,
OrderSide::Buy,
Quantity::from("2.000"),
Price::from("3000.00"),
Money::from("1.00 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
let new_fill_report = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
new_trade_id,
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3100.00"),
Money::from("0.50 USDT"),
LiquiditySide::Taker,
Some(client_order_id),
None,
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("3.000"),
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
None,
Some(dec!(3033.333333)),
);
mass_status.add_order_reports(vec![order_report]);
mass_status.add_fill_reports(vec![known_fill_report, new_fill_report]);
mass_status.add_position_reports(vec![position_report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let order = cache.order(&client_order_id).unwrap();
let position = cache.position(&position_id).unwrap();
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(order.filled_qty(), Quantity::from("3.000"));
assert_eq!(position.quantity, Quantity::from("3.000"));
assert_eq!(position.realized_pnl, Some(Money::from("-1.50 USDT")));
assert_eq!(position.commissions(), vec![Money::from("1.50 USDT")]);
assert_eq!(position.trade_ids.len(), 2);
assert!(position.trade_ids.contains(&known_trade_id));
assert!(position.trade_ids.contains(&new_trade_id));
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_partial_window_known_fill_does_not_reapply_economics(
#[case] generate_missing_orders: bool,
) {
let config = ExecutionManagerConfig {
generate_missing_orders,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let strategy_id = StrategyId::from("STRATEGY-001");
let opening_order_id = ClientOrderId::from("O-PARTIAL-WINDOW-OPEN");
let closing_order_id = ClientOrderId::from("O-PARTIAL-WINDOW-CLOSE");
let closing_venue_order_id = VenueOrderId::from("V-PARTIAL-WINDOW-CLOSE");
let opening_trade_id = TradeId::from("T-PARTIAL-WINDOW-OPEN");
let closing_trade_id = TradeId::from("T-PARTIAL-WINDOW-CLOSE");
let position_id = PositionId::new(format!("{instrument_id}-{strategy_id}"));
ctx.add_instrument(instrument.clone());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Netting);
let mut opening_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(opening_order_id)
.strategy_id(strategy_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("5.000"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(
&mut opening_order,
VenueOrderId::from("V-PARTIAL-WINDOW-OPEN"),
);
let opening_fill = TestOrderEventStubs::filled(
&opening_order,
&instrument,
Some(opening_trade_id),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("5.000")),
Some(LiquiditySide::Maker),
Some(Money::from("2.50 USDT")),
Some(UnixNanos::from(1_000_000)),
Some(test_account_id()),
);
let mut position = Position::new(&instrument, opening_fill.into());
let mut closing_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(closing_order_id)
.strategy_id(strategy_id)
.instrument_id(instrument_id)
.side(OrderSide::Sell)
.quantity(Quantity::from("2.000"))
.price(Price::from("3100.00"))
.build();
apply_submitted_and_accepted(&mut closing_order, closing_venue_order_id);
let closing_fill = TestOrderEventStubs::filled(
&closing_order,
&instrument,
Some(closing_trade_id),
Some(position_id),
Some(Price::from("3100.00")),
Some(Quantity::from("2.000")),
Some(LiquiditySide::Taker),
Some(Money::from("1.00 USDT")),
Some(UnixNanos::from(2_000_000)),
Some(test_account_id()),
);
position.apply(&closing_fill.clone().into());
ctx.cache
.borrow_mut()
.add_position(&position, OmsType::Netting)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let order_report = create_order_status_report_for_side(
Some(closing_order_id),
closing_venue_order_id,
instrument_id,
OrderSide::Sell,
OrderStatus::Filled,
Quantity::from("2.000"),
Quantity::from("2.000"),
)
.with_avg_px(dec!(3100.0));
let fill_report = FillReport::new(
test_account_id(),
instrument_id,
closing_venue_order_id,
closing_trade_id,
OrderSide::Sell,
Quantity::from("2.000"),
Price::from("3100.00"),
Money::from("1.00 USDT"),
LiquiditySide::Taker,
Some(closing_order_id),
None,
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("3.000"),
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
None,
Some(dec!(3000.00)),
);
mass_status.add_order_reports(vec![order_report]);
mass_status.add_fill_reports(vec![fill_report]);
mass_status.add_position_reports(vec![position_report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let order = cache.order(&closing_order_id).unwrap();
let position = cache.position(&position_id).unwrap();
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(position.quantity, Quantity::from("3.000"));
assert_eq!(position.realized_pnl, Some(Money::from("196.50 USDT")));
assert_eq!(position.commissions(), vec![Money::from("3.50 USDT")]);
assert_eq!(position.trade_ids.len(), 2);
assert!(position.trade_ids.contains(&opening_trade_id));
assert!(position.trade_ids.contains(&closing_trade_id));
}
#[tokio::test]
async fn test_fill_before_retained_netting_lifecycle_projects_order_only() {
let config = ExecutionManagerConfig {
generate_missing_orders: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let strategy_id = StrategyId::from("STRATEGY-001");
let old_order_id = ClientOrderId::from("O-PRIOR-LIFECYCLE");
let old_venue_order_id = VenueOrderId::from("V-PRIOR-LIFECYCLE");
let old_trade_id = TradeId::from("T-PRIOR-LIFECYCLE");
let current_order_id = ClientOrderId::from("O-CURRENT-LIFECYCLE");
let current_venue_order_id = VenueOrderId::from("V-CURRENT-LIFECYCLE");
let current_trade_id = TradeId::from("T-CURRENT-LIFECYCLE");
let position_id = PositionId::new(format!("{instrument_id}-{strategy_id}"));
ctx.add_instrument(instrument.clone());
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
ctx.exec_engine
.borrow_mut()
.register_oms_type(strategy_id, OmsType::Netting);
let mut current_order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(current_order_id)
.strategy_id(strategy_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.000"))
.price(Price::from("3200.00"))
.build();
apply_submitted_and_accepted(&mut current_order, current_venue_order_id);
let current_fill = TestOrderEventStubs::filled(
¤t_order,
&instrument,
Some(current_trade_id),
Some(position_id),
Some(Price::from("3200.00")),
Some(Quantity::from("1.000")),
Some(LiquiditySide::Maker),
Some(Money::from("0.50 USDT")),
Some(UnixNanos::from(3_000_000)),
Some(test_account_id()),
);
let position = Position::new(&instrument, current_fill.into());
ctx.cache
.borrow_mut()
.add_position(&position, OmsType::Netting)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let old_order_report = create_order_status_report(
Some(old_order_id),
old_venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("1.000"),
Quantity::from("1.000"),
)
.with_avg_px(dec!(3000.0));
let current_order_report = create_order_status_report(
Some(current_order_id),
current_venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("1.000"),
Quantity::from("1.000"),
)
.with_avg_px(dec!(3200.0));
let old_fill_report = FillReport::new(
test_account_id(),
instrument_id,
old_venue_order_id,
old_trade_id,
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(old_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
let current_fill_report = FillReport::new(
test_account_id(),
instrument_id,
current_venue_order_id,
current_trade_id,
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3200.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(current_order_id),
None,
UnixNanos::from(3_000_000),
UnixNanos::from(3_000_000),
None,
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("1.000"),
UnixNanos::from(3_000_000),
UnixNanos::from(3_000_000),
None,
None,
Some(dec!(3200.00)),
);
mass_status.add_order_reports(vec![old_order_report, current_order_report]);
mass_status.add_fill_reports(vec![old_fill_report, current_fill_report]);
mass_status.add_position_reports(vec![position_report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let old_order = cache.order(&old_order_id).unwrap();
let current_order = cache.order(¤t_order_id).unwrap();
let position = cache.position(&position_id).unwrap();
assert_eq!(old_order.status(), OrderStatus::Filled);
assert_eq!(current_order.status(), OrderStatus::Filled);
assert_eq!(position.quantity, Quantity::from("1.000"));
assert_eq!(position.realized_pnl, Some(Money::from("-0.50 USDT")));
assert_eq!(position.commissions(), vec![Money::from("0.50 USDT")]);
assert_eq!(position.trade_ids.len(), 1);
assert!(position.trade_ids.contains(¤t_trade_id));
assert!(!position.trade_ids.contains(&old_trade_id));
}
#[tokio::test]
async fn test_filled_report_with_reduced_quantity_closes_partially_filled_order() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = instrument.id();
let client_order_id = ClientOrderId::from("O-REDUCED-FILLED");
let venue_order_id = VenueOrderId::from("V-REDUCED-FILLED");
ctx.add_instrument(instrument.clone());
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("10.000"))
.price(Price::from("3000.00"))
.build();
apply_submitted_and_accepted(&mut order, venue_order_id);
let fill = TestOrderEventStubs::filled(
&order,
&instrument,
Some(TradeId::from("T-REDUCED-FILLED")),
None,
Some(Price::from("3000.00")),
Some(Quantity::from("5.000")),
Some(LiquiditySide::Maker),
Some(Money::from("0.50 USDT")),
Some(UnixNanos::from(1_000_000)),
Some(test_account_id()),
);
order.apply(fill).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("5.000"),
Quantity::from("5.000"),
)
.with_avg_px(dec!(3000.0));
mass_status.add_order_reports(vec![report]);
ctx.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(order.quantity(), Quantity::from("5.000"));
assert_eq!(order.filled_qty(), Quantity::from("5.000"));
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_order_without_instrument() {
let mut ctx = TestContext::new();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
VenueOrderId::from("V-EXT-001"),
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_sorts_events_chronologically() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "2.0", "3000.00");
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill2 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-002"),
OrderSide::Buy,
Quantity::from("0.5"),
Price::from("3001.00"),
Money::from("0.25 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(2_000_000), UnixNanos::from(2_000_000),
None,
);
let fill1 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("0.5"),
Price::from("3000.00"),
Money::from("0.25 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000), UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill2, fill1]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(result.events[0].ts_event() < result.events[1].ts_event());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_order_generates_rejection_after_max_retries() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-001", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Rejected(_)));
if let OrderEventAny::Rejected(rejected) = &result.events[0] {
assert_eq!(rejected.client_order_id, client_order_id);
assert_eq!(rejected.reason.as_str(), "INFLIGHT_TIMEOUT");
}
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_timeout_uses_monotonic_gate_and_domain_event_timestamp() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-SPLIT");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-SPLIT", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
let domain_ts = ctx.clock.borrow().timestamp_ns();
advance_clock(dst::time::Duration::from_millis(200)).await;
let result = ctx.manager.check_inflight_orders();
assert_eq!(result.events.len(), 1);
assert_eq!(result.events[0].ts_event(), domain_ts);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_check_skips_filtered_order_ids() {
let filtered_id = ClientOrderId::from("O-FILTERED");
let mut config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
config.filtered_client_order_ids.insert(filtered_id);
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let order = create_submitted_order(
"O-FILTERED",
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
);
ctx.add_order(order);
ctx.manager.register_inflight(filtered_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(result.events.is_empty());
}
#[rstest]
fn test_config_default_values() {
let config = ExecutionManagerConfig::default();
assert!(config.reconciliation);
assert_eq!(config.lookback_mins, Some(60));
assert!(!config.filter_unclaimed_external);
assert!(!config.filter_position_reports);
assert!(config.generate_missing_orders);
assert_eq!(config.inflight_check_interval_ms, 2_000);
assert_eq!(config.inflight_threshold_ms, 5_000);
assert_eq!(config.inflight_max_retries, 5);
}
#[rstest]
fn test_config_with_trader_id() {
let trader_id = TraderId::from("TRADER-001");
let config = ExecutionManagerConfig::default().with_trader_id(trader_id);
assert_eq!(config.trader_id, trader_id);
}
#[rstest]
fn test_purge_operations_do_nothing_when_disabled() {
let config = ExecutionManagerConfig {
purge_closed_orders_buffer_mins: None,
purge_closed_positions_buffer_mins: None,
purge_account_events_lookback_mins: None,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.manager.purge_closed_orders();
ctx.manager.purge_closed_positions();
ctx.manager.purge_account_events();
}
#[tokio::test]
async fn test_reconcile_mass_status_accepted_order_canceled_at_venue() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let mut order =
create_submitted_order("O-001", instrument_id, OrderSide::Buy, "1.0", "3000.00");
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0"),
)
.with_cancel_reason("USER_REQUEST".to_string());
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Canceled(_)));
if let OrderEventAny::Canceled(canceled) = &result.events[0] {
assert_eq!(canceled.client_order_id, client_order_id);
assert!(canceled.reconciliation); }
}
#[tokio::test]
async fn test_reconcile_mass_status_accepted_order_expired_at_venue() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-002");
let venue_order_id = VenueOrderId::from("V-002");
ctx.add_instrument(test_instrument());
let mut order =
create_submitted_order("O-002", instrument_id, OrderSide::Sell, "2.0", "3100.00");
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Expired,
Quantity::from("2.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Expired(_)));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_increments_retry_count_before_max() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 3,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-001", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result1 = ctx.manager.check_inflight_orders();
assert!(result1.events.is_empty()); assert_eq!(result1.queries.len(), 1);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result2 = ctx.manager.check_inflight_orders();
assert!(result2.events.is_empty()); assert_eq!(result2.queries.len(), 1);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result3 = ctx.manager.check_inflight_orders();
assert_eq!(result3.events.len(), 1);
assert!(matches!(result3.events[0], OrderEventAny::Rejected(_)));
assert!(result3.queries.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_pending_update_generates_canceled() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-PENDING-UPD");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let order = create_pending_update_order(
"O-PENDING-UPD",
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
venue_order_id,
);
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert_eq!(result.events.len(), 1);
assert!(
matches!(result.events[0], OrderEventAny::Canceled(_)),
"Expected Canceled for PendingUpdate, was {:?}",
result.events[0]
);
if let OrderEventAny::Canceled(canceled) = &result.events[0] {
assert_eq!(canceled.client_order_id, client_order_id);
}
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_pending_cancel_generates_canceled() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-PENDING-CXL");
let venue_order_id = VenueOrderId::from("V-002");
ctx.add_instrument(test_instrument());
let order = create_pending_cancel_order(
"O-PENDING-CXL",
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
venue_order_id,
);
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert_eq!(result.events.len(), 1);
assert!(
matches!(result.events[0], OrderEventAny::Canceled(_)),
"Expected Canceled for PendingCancel, was {:?}",
result.events[0]
);
if let OrderEventAny::Canceled(canceled) = &result.events[0] {
assert_eq!(canceled.client_order_id, client_order_id);
}
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_generates_query_before_max_retries() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 3,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-QUERY");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-QUERY", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(
result.events.is_empty(),
"Should not generate terminal events yet"
);
assert_eq!(result.queries.len(), 1, "Should generate one QueryOrder");
if let TradingCommand::QueryOrder(query) = &result.queries[0] {
assert_eq!(query.client_order_id, client_order_id);
} else {
panic!("Expected QueryOrder, was {:?}", result.queries[0]);
}
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_no_query_at_max_retries() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 2,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-MAX");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-MAX", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result1 = ctx.manager.check_inflight_orders();
assert!(result1.events.is_empty());
assert_eq!(result1.queries.len(), 1);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result2 = ctx.manager.check_inflight_orders();
assert_eq!(
result2.events.len(),
1,
"Should generate terminal event at max retries"
);
assert!(
result2.queries.is_empty(),
"Should not generate queries at max retries"
);
assert!(matches!(result2.events[0], OrderEventAny::Rejected(_)));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_query_preserves_client_id_routing() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 3,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-ROUTED");
let client_id = ClientId::from("EXEC-002");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-ROUTED", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order_with_client_id(order, client_id);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert_eq!(result.queries.len(), 1);
if let TradingCommand::QueryOrder(query) = &result.queries[0] {
assert_eq!(
query.client_id,
Some(client_id),
"QueryOrder should preserve the execution client routing"
);
assert_eq!(query.client_order_id, client_order_id);
} else {
panic!("Expected QueryOrder, was {:?}", result.queries[0]);
}
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_query_throttled_within_threshold() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 5,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-THROTTLE");
ctx.add_instrument(test_instrument());
let order = create_submitted_order(
"O-THROTTLE",
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
);
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result1 = ctx.manager.check_inflight_orders();
assert_eq!(result1.queries.len(), 1);
let result2 = ctx.manager.check_inflight_orders();
assert!(result2.queries.is_empty(), "Query should be throttled");
assert!(result2.events.is_empty());
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result3 = ctx.manager.check_inflight_orders();
assert_eq!(result3.queries.len(), 1);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_accepted_order_at_max_retries_no_event() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-ACCEPTED");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let order = create_accepted_order(
"O-ACCEPTED",
instrument_id,
OrderSide::Buy,
"1.0",
"3000.00",
venue_order_id,
);
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(result.events.is_empty());
assert!(result.queries.is_empty());
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result2 = ctx.manager.check_inflight_orders();
assert!(result2.events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_order_not_in_cache_at_max_retries_no_event() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let client_order_id = ClientOrderId::from("O-MISSING");
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(result.events.is_empty());
assert!(result.queries.is_empty());
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result2 = ctx.manager.check_inflight_orders();
assert!(result2.events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_inflight_terminal_event_clears_tracking() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 1,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-TERM");
ctx.add_instrument(test_instrument());
let order = create_submitted_order("O-TERM", instrument_id, OrderSide::Buy, "1.0", "3000.00");
ctx.add_order(order);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result1 = ctx.manager.check_inflight_orders();
assert_eq!(result1.events.len(), 1);
assert!(matches!(result1.events[0], OrderEventAny::Rejected(_)));
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result2 = ctx.manager.check_inflight_orders();
assert!(result2.events.is_empty());
assert!(result2.queries.is_empty());
}
#[rstest]
fn test_observe_fill_report_without_client_order_id_uses_cache_fallback() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 5,
open_check_threshold_ns: 1_000_000_000,
max_single_order_queries_per_cycle: 5,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let trade_id = TradeId::from("T-FALLBACK");
ctx.add_instrument(test_instrument());
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
ctx.manager.register_inflight(client_order_id);
let fill_report = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
trade_id,
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::new(0.0, Currency::USDT()),
LiquiditySide::Maker,
None, None,
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
);
let report = ExecutionReport::Fill(Box::new(fill_report));
ctx.manager.observe_execution_report(&report);
let queries = ctx.manager.check_open_order_queries();
assert!(queries.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_external_order_partially_filled() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None, VenueOrderId::from("V-EXT-PARTIAL"),
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("3.0"),
)
.with_avg_px(dec!(3000.50));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled) = &result.events[1] {
assert_eq!(filled.last_qty, Quantity::from("3.0"));
assert!(filled.reconciliation);
}
let client_order_id = ClientOrderId::from("V-EXT-PARTIAL");
let order = ctx.get_order(&client_order_id);
assert!(order.is_some());
}
#[tokio::test]
async fn test_reconcile_mass_status_order_already_in_sync() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-SYNC");
let venue_order_id = VenueOrderId::from("V-SYNC");
ctx.add_instrument(test_instrument());
let mut order =
create_submitted_order("O-SYNC", instrument_id, OrderSide::Buy, "5.0", "3000.00");
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("5.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_clear_recon_tracking_removes_inflight() {
let config = ExecutionManagerConfig {
inflight_threshold_ms: 100,
inflight_max_retries: 5,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let client_order_id = ClientOrderId::from("O-001");
ctx.manager.register_inflight(client_order_id);
ctx.manager.clear_recon_tracking(&client_order_id, true);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(result.events.is_empty());
}
fn create_accepted_order(
client_order_id: &str,
instrument_id: InstrumentId,
side: OrderSide,
quantity: &str,
price: &str,
venue_order_id: VenueOrderId,
) -> OrderAny {
let mut order = create_submitted_order(client_order_id, instrument_id, side, quantity, price);
apply_submitted_and_accepted(&mut order, venue_order_id);
order
}
fn apply_submitted_and_accepted(order: &mut OrderAny, venue_order_id: VenueOrderId) {
if order.status() == OrderStatus::Initialized {
let submitted = TestOrderEventStubs::submitted(order, test_account_id());
order.apply(submitted).unwrap();
}
let accepted = TestOrderEventStubs::accepted(order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
}
fn create_pending_update_order(
client_order_id: &str,
instrument_id: InstrumentId,
side: OrderSide,
quantity: &str,
price: &str,
venue_order_id: VenueOrderId,
) -> OrderAny {
let mut order = create_accepted_order(
client_order_id,
instrument_id,
side,
quantity,
price,
venue_order_id,
);
let pending = OrderEventAny::PendingUpdate(
OrderPendingUpdateSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.account_id(test_account_id())
.venue_order_id(venue_order_id)
.build(),
);
order.apply(pending).unwrap();
order
}
fn create_pending_cancel_order(
client_order_id: &str,
instrument_id: InstrumentId,
side: OrderSide,
quantity: &str,
price: &str,
venue_order_id: VenueOrderId,
) -> OrderAny {
let mut order = create_accepted_order(
client_order_id,
instrument_id,
side,
quantity,
price,
venue_order_id,
);
let pending = OrderEventAny::PendingCancel(
OrderPendingCancelSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.account_id(test_account_id())
.venue_order_id(venue_order_id)
.build(),
);
order.apply(pending).unwrap();
order
}
#[tokio::test]
async fn test_inferred_fill_generated_when_venue_reports_filled() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-FILL-001");
let venue_order_id = VenueOrderId::from("V-FILL-001");
ctx.add_instrument(test_instrument());
let order = create_accepted_order(
"O-FILL-001",
instrument_id,
OrderSide::Buy,
"10.0",
"3000.00",
venue_order_id,
);
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"), )
.with_avg_px(dec!(3001.50));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled) = &result.events[0] {
assert_eq!(filled.client_order_id, client_order_id);
assert_eq!(filled.last_qty, Quantity::from("5.0"));
assert!(filled.reconciliation);
assert_eq!(filled.trade_id.as_str().len(), 36);
}
}
#[tokio::test]
async fn test_inferred_fill_uses_avg_px_for_first_fill() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-AVG-001");
let venue_order_id = VenueOrderId::from("V-AVG-001");
ctx.add_instrument(test_instrument());
let order = create_accepted_order(
"O-AVG-001",
instrument_id,
OrderSide::Buy,
"10.0",
"3000.00",
venue_order_id,
);
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("3.0"),
)
.with_avg_px(dec!(2999.75));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
if let OrderEventAny::Filled(filled) = &result.events[0] {
assert_eq!(filled.last_px.as_f64(), 2999.75);
}
}
#[tokio::test]
async fn test_no_inferred_fill_when_already_in_sync() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-SYNC-001");
let venue_order_id = VenueOrderId::from("V-SYNC-001");
ctx.add_instrument(test_instrument());
let mut order = create_accepted_order(
"O-SYNC-001",
instrument_id,
OrderSide::Buy,
"10.0",
"3000.00",
venue_order_id,
);
let fill = TestOrderEventStubs::filled(
&order,
&test_instrument(),
None, None, None, Some(Quantity::from("5.0")), None, None, None, None, );
order.apply(fill).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"), );
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_fill_qty_mismatch_venue_less_generates_fill_void() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-MISMATCH");
let venue_order_id = VenueOrderId::from("V-MISMATCH");
ctx.add_instrument(test_instrument());
let mut order = create_accepted_order(
"O-MISMATCH",
instrument_id,
OrderSide::Buy,
"10.0",
"3000.00",
venue_order_id,
);
let fill = OrderFilledTestBuilder::new(&order, &test_instrument())
.last_qty(Quantity::from("5.0"))
.without_position_id()
.build();
order.apply(fill).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("3.0"), );
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let order = ctx
.cache
.borrow()
.order_owned(&client_order_id)
.expect("order cached");
assert_eq!(result.events.len(), 1);
let OrderEventAny::FillVoided(voided) = &result.events[0] else {
panic!("expected OrderFillVoided event");
};
assert_eq!(voided.voided_qty, Quantity::from("2.0"));
assert!(voided.is_reopened);
assert_eq!(order.status(), OrderStatus::PartiallyFilled);
assert_eq!(order.filled_qty(), Quantity::from("3.0"));
assert_eq!(order.voided_qty(), Quantity::from("2.0"));
}
#[tokio::test]
async fn test_market_order_inferred_fill_is_taker() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-MKT-001");
let venue_order_id = VenueOrderId::from("V-MKT-001");
ctx.add_instrument(test_instrument());
let mut order = OrderTestBuilder::new(OrderType::Market)
.client_order_id(ClientOrderId::from("O-MKT-001"))
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("10.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = OrderStatusReport::new(
test_account_id(),
instrument_id,
Some(client_order_id),
venue_order_id,
OrderSide::Buy,
OrderType::Market,
TimeInForce::Ioc,
OrderStatus::Filled,
Quantity::from("10.0"),
Quantity::from("10.0"),
UnixNanos::default(),
UnixNanos::default(),
UnixNanos::default(),
Some(UUID4::new()),
)
.with_avg_px(dec!(3005.00));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
if let OrderEventAny::Filled(filled) = &result.events[0] {
assert_eq!(filled.liquidity_side, LiquiditySide::Taker);
}
}
#[tokio::test]
async fn test_pending_cancel_status_no_event() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-PEND-001");
let venue_order_id = VenueOrderId::from("V-PEND-001");
ctx.add_instrument(test_instrument());
let order = create_accepted_order(
"O-PEND-001",
instrument_id,
OrderSide::Buy,
"10.0",
"3000.00",
venue_order_id,
);
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PendingCancel,
Quantity::from("10.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_incremental_fill_calculates_weighted_price() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-INCR-001");
let venue_order_id = VenueOrderId::from("V-INCR-001");
ctx.add_instrument(test_instrument());
let mut order = create_accepted_order(
"O-INCR-001",
instrument_id,
OrderSide::Buy,
"10.0",
"3000.00",
venue_order_id,
);
let fill = TestOrderEventStubs::filled(
&order,
&test_instrument(),
None, None, Some(Price::from("3000.00")), Some(Quantity::from("5.0")), None, None, None, None, );
order.apply(fill).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("8.0"),
)
.with_avg_px(dec!(3002.50));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
if let OrderEventAny::Filled(filled) = &result.events[0] {
assert_eq!(filled.last_qty, Quantity::from("3.0"));
let expected_px = (8.0 * 3002.50 - 5.0 * 3000.00) / 3.0;
assert!((filled.last_px.as_f64() - expected_px).abs() < 0.01);
}
}
#[rstest]
#[tokio::test]
async fn test_mass_status_skips_exact_duplicate_orders() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("1.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0.0"),
)
.with_price(Price::from("100.0"));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_mass_status_deduplicates_within_batch() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("1.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report1 = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
let report2 = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![report1, report2]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
}
#[rstest]
#[tokio::test]
async fn test_mass_status_reconciles_when_status_differs() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("1.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Canceled(_)));
}
#[rstest]
#[tokio::test]
async fn test_mass_status_reconciles_when_filled_qty_differs() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"),
)
.with_avg_px(dec!(100.0));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
if let OrderEventAny::Filled(filled) = &result.events[0] {
assert_eq!(filled.last_qty, Quantity::from("5.0"));
} else {
panic!("Expected OrderFilled event");
}
}
#[rstest]
#[tokio::test]
async fn test_mass_status_matches_order_by_venue_order_id() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("1.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Canceled(_)));
if let OrderEventAny::Canceled(canceled) = &result.events[0] {
assert_eq!(canceled.client_order_id, client_order_id);
}
}
#[rstest]
#[tokio::test]
async fn test_mass_status_matches_order_by_venue_order_id_with_mismatched_client_id() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("1.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let wrong_client_order_id = ClientOrderId::from("O-WRONG");
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(wrong_client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Canceled,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Canceled(_)));
if let OrderEventAny::Canceled(canceled) = &result.events[0] {
assert_eq!(canceled.client_order_id, client_order_id);
}
}
#[tokio::test]
async fn test_reconcile_mass_status_indexes_venue_order_id_for_accepted_orders() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let client_order_id = ClientOrderId::from("O-TEST");
let venue_order_id = VenueOrderId::from("V-123");
let mut order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.client_order_id(client_order_id)
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order.clone());
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
Venue::from("SIM"),
UnixNanos::default(),
Some(UUID4::new()),
);
mass_status.add_order_reports(vec![report]);
let _events = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(
ctx.cache.borrow().client_order_id(&venue_order_id),
Some(&client_order_id),
"venue_order_id should be indexed after reconciliation"
);
}
#[tokio::test]
async fn test_reconcile_mass_status_indexes_venue_order_id_for_external_orders() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let venue_order_id = VenueOrderId::from("V-EXT-001");
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
Venue::from("SIM"),
UnixNanos::default(),
Some(UUID4::new()),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result.events.is_empty(),
"Should generate events for external order"
);
let cache_borrow = ctx.cache.borrow();
let indexed_client_id = cache_borrow.client_order_id(&venue_order_id);
assert!(
indexed_client_id.is_some(),
"venue_order_id should be indexed for external order"
);
}
#[tokio::test]
async fn test_reconcile_mass_status_indexes_venue_order_id_for_filled_orders() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let client_order_id = ClientOrderId::from("O-FILLED");
let venue_order_id = VenueOrderId::from("V-456");
let mut order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.client_order_id(client_order_id)
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
let filled = TestOrderEventStubs::filled(
&order,
&instrument,
Some(TradeId::from("T-1")),
None, Some(Price::from("1.0")), Some(Quantity::from("1.0")), Some(LiquiditySide::Taker), Some(Money::from("0.01 USD")), None, Some(test_account_id()),
);
order.apply(filled).unwrap();
ctx.add_order(order.clone());
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("1.0"),
Quantity::from("1.0"),
);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
Venue::from("SIM"),
UnixNanos::default(),
Some(UUID4::new()),
);
mass_status.add_order_reports(vec![report]);
let _events = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache_borrow = ctx.cache.borrow();
assert_eq!(
cache_borrow.client_order_id(&venue_order_id),
Some(&client_order_id),
"venue_order_id should be indexed for filled order"
);
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_orders_without_loaded_instruments() {
let mut ctx = TestContext::new();
let loaded_instrument_id = test_instrument_id();
let loaded_instrument = test_instrument();
ctx.add_instrument(loaded_instrument.clone());
let unloaded_instrument_id = InstrumentId::from("BTCUSDT.SIM");
let loaded_venue_order_id = VenueOrderId::from("V-LOADED");
let unloaded_venue_order_id = VenueOrderId::from("V-UNLOADED");
let loaded_report = create_order_status_report(
Some(ClientOrderId::from("O-LOADED")),
loaded_venue_order_id,
loaded_instrument_id,
OrderStatus::Filled,
Quantity::from("1.0"),
Quantity::from("1.0"),
);
let unloaded_report = create_order_status_report(
Some(ClientOrderId::from("O-UNLOADED")),
unloaded_venue_order_id,
unloaded_instrument_id,
OrderStatus::Filled,
Quantity::from("1.0"),
Quantity::from("1.0"),
);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
Venue::from("SIM"),
UnixNanos::default(),
Some(UUID4::new()),
);
mass_status.add_order_reports(vec![loaded_report, unloaded_report]);
let _events = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache_borrow = ctx.cache.borrow();
let loaded_client_id = cache_borrow.client_order_id(&loaded_venue_order_id);
assert!(
loaded_client_id.is_some(),
"Loaded instrument order should be indexed"
);
let unloaded_client_id = cache_borrow.client_order_id(&unloaded_venue_order_id);
assert!(
unloaded_client_id.is_none(),
"Unloaded instrument order should not be indexed (skipped during reconciliation)"
);
}
#[tokio::test]
async fn test_reconcile_mass_status_creates_position_from_position_report() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled) = &result.events[1] {
assert_eq!(filled.last_qty, Quantity::from("5.0"));
assert_eq!(filled.last_px.as_f64(), 3000.50);
assert!(filled.reconciliation);
}
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_flat_position_report() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Flat,
Quantity::from("0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
None,
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_position_report_when_filtered() {
let config = ExecutionManagerConfig {
filter_position_reports: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_creates_short_position_from_report() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(2950.25)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 2);
assert!(matches!(result.events[0], OrderEventAny::Accepted(_)));
assert!(matches!(result.events[1], OrderEventAny::Filled(_)));
if let OrderEventAny::Filled(filled) = &result.events[1] {
assert_eq!(filled.last_qty, Quantity::from("3.0"));
assert_eq!(filled.order_side, OrderSide::Sell);
}
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_position_report_when_fills_exist() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let mut order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "5.0", "3000.00");
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Filled(_)));
}
fn create_test_position(
instrument: &InstrumentAny,
position_id: PositionId,
side: OrderSide,
qty: &str,
price: &str,
) -> Position {
create_test_position_for_account(instrument, position_id, side, qty, price, test_account_id())
}
fn create_test_position_for_account(
instrument: &InstrumentAny,
position_id: PositionId,
side: OrderSide,
qty: &str,
price: &str,
account_id: AccountId,
) -> Position {
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(side)
.quantity(Quantity::from(qty))
.build();
let fill = TestOrderEventStubs::filled(
&order,
instrument,
Some(TradeId::new("T-001")),
Some(position_id),
Some(Price::from(price)),
Some(Quantity::from(qty)),
None,
None,
None,
Some(account_id),
);
let order_filled: OrderFilled = fill.into();
Position::new(instrument, order_filled)
}
fn close_test_long_position(
mut position: Position,
instrument: &InstrumentAny,
qty: &str,
trade_id: TradeId,
) -> Position {
let close_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(qty))
.build();
let close_fill = TestOrderEventStubs::filled(
&close_order,
instrument,
Some(trade_id),
Some(position.id),
Some(Price::from("3000.00")),
Some(Quantity::from(qty)),
None,
None,
None,
Some(position.account_id),
);
let close_filled: OrderFilled = close_fill.into();
position.apply(&close_filled);
position
}
#[tokio::test]
async fn test_reconcile_mass_status_iterates_all_position_reports() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report_long = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-LONG-001")),
Some(dec!(3000.50)),
);
let position_report_short = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-SHORT-001")),
Some(dec!(3100.00)),
);
mass_status.add_position_reports(vec![position_report_long, position_report_short]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
let has_buy_fill = fill_events.iter().any(|f| f.order_side == OrderSide::Buy);
let has_sell_fill = fill_events.iter().any(|f| f.order_side == OrderSide::Sell);
assert!(has_buy_fill, "Should have BUY fill for long position");
assert!(has_sell_fill, "Should have SELL fill for short position");
let cache = ctx.cache.borrow();
let positions = cache.positions(None, None, None, None, None);
assert_eq!(positions.len(), 2, "Should have 2 positions in cache");
}
#[tokio::test]
async fn test_reconcile_mass_status_routes_to_hedging_with_venue_position_id() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-HEDGE-001")),
Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(!result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_routes_to_netting_without_venue_position_id() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None, Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(!result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_hedge_position_when_fills_in_batch() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let venue_position_id = PositionId::from("P-HEDGE-001");
ctx.add_instrument(test_instrument());
let order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
Some(venue_position_id),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(venue_position_id),
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Filled(_)));
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_hedge_position_when_filled_order_in_batch() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let venue_order_id = VenueOrderId::from("V-001");
let venue_position_id = PositionId::from("P-HEDGE-001");
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let order_report = OrderStatusReport::new(
test_account_id(),
instrument_id,
None,
venue_order_id,
OrderSide::Buy,
OrderType::Market,
TimeInForce::Gtc,
OrderStatus::Filled,
Quantity::from("5.0"),
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
)
.with_avg_px(dec!(3000.0))
.with_venue_position_id(venue_position_id);
mass_status.add_order_reports(vec![order_report]);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(venue_position_id),
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let filled_count = result
.events
.iter()
.filter(|e| matches!(e, OrderEventAny::Filled(_)))
.count();
assert_eq!(filled_count, 1, "Expected exactly 1 fill event");
}
#[tokio::test]
async fn test_reconcile_mass_status_skips_hedge_position_when_fills_lack_position_id() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
ctx.add_instrument(test_instrument());
let order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None, UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-HEDGE-001")), Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert_eq!(result.events.len(), 1);
assert!(matches!(result.events[0], OrderEventAny::Filled(_)));
}
#[tokio::test]
async fn test_reconcile_hedge_does_not_skip_unrelated_positions() {
let config = ExecutionManagerConfig {
generate_missing_orders: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let position_id_1 = PositionId::from("P-HEDGE-001");
let position_id_2 = PositionId::from("P-HEDGE-002");
ctx.add_instrument(test_instrument());
let order = create_limit_order("O-001", instrument_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
Some(position_id_1), UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let position_report_1 = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(position_id_1),
Some(dec!(3000.00)),
);
let position_report_2 = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(position_id_2), Some(dec!(3100.00)),
);
mass_status.add_position_reports(vec![position_report_1, position_report_2]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let filled_count = result
.events
.iter()
.filter(|e| matches!(e, OrderEventAny::Filled(_)))
.count();
assert!(
filled_count >= 2,
"Expected at least 2 fill events, was {filled_count}"
);
}
#[tokio::test]
async fn test_reconcile_hedge_position_matching_quantities() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
let position_id = PositionId::from("P-HEDGE-001");
ctx.add_instrument(instrument.clone());
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(position_id),
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
result.events.is_empty(),
"Expected no events when positions match, was {}",
result.events.len()
);
}
#[tokio::test]
async fn test_reconcile_hedge_position_discrepancy_generates_order() {
let config = ExecutionManagerConfig {
generate_missing_orders: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = test_instrument_id();
let position_id = PositionId::from("P-HEDGE-001");
ctx.add_instrument(instrument.clone());
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("8.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(position_id),
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result.events.is_empty(),
"Expected events for position discrepancy reconciliation"
);
}
#[tokio::test]
async fn test_reconcile_missing_hedge_position_generates_order() {
let config = ExecutionManagerConfig {
generate_missing_orders: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-MISSING-001")),
Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result.events.is_empty(),
"Expected events for missing position creation"
);
}
#[tokio::test]
async fn test_reconcile_hedge_position_discrepancy_disabled() {
let config = ExecutionManagerConfig {
generate_missing_orders: false, ..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = test_instrument_id();
let position_id = PositionId::from("P-HEDGE-001");
ctx.add_instrument(instrument.clone());
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("8.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(position_id),
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
result.events.is_empty(),
"Expected no events when generate_missing_orders is disabled"
);
}
#[tokio::test]
async fn test_reconcile_hedge_position_both_flat() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Flat,
Quantity::from("0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-FLAT-001")),
None,
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[tokio::test]
async fn test_reconcile_hedge_short_position() {
let config = ExecutionManagerConfig {
generate_missing_orders: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from("P-SHORT-001")),
Some(dec!(3100.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(!result.events.is_empty());
let has_sell = result.events.iter().any(|e| {
if let OrderEventAny::Filled(filled) = e {
filled.order_side == OrderSide::Sell
} else {
false
}
});
assert!(has_sell, "Expected a sell order for short position");
}
#[tokio::test]
async fn test_reconcile_mass_status_deduplicates_netting_reports_same_instrument() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report_1 = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None, Some(dec!(3000.50)),
);
let position_report_2 = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
None, Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report_1, position_report_2]);
let _result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let positions = cache.positions(None, None, None, None, None);
assert_eq!(
positions.len(),
1,
"Should have exactly 1 position (duplicates skipped), was {}",
positions.len()
);
assert_eq!(
positions[0].quantity,
Quantity::from("5.0"),
"Position should have qty 5.0"
);
}
#[tokio::test]
async fn test_reconcile_mass_status_deduplicates_hedge_reports_same_position_id() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let venue_position_id = PositionId::from("P-HEDGE-001");
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report_1 = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(venue_position_id),
Some(dec!(3000.50)),
);
let position_report_2 = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
Some(venue_position_id), Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report_1, position_report_2]);
let _result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let positions = cache.positions(None, None, None, None, None);
assert_eq!(
positions.len(),
1,
"Should have exactly 1 position (duplicates skipped), was {}",
positions.len()
);
assert_eq!(
positions[0].id, venue_position_id,
"Position should have correct ID"
);
assert_eq!(
positions[0].quantity,
Quantity::from("5.0"),
"Position should have qty 5.0"
);
}
#[tokio::test]
async fn test_adjust_fills_creates_synthetic_for_partial_window() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None, Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let venue_order_id = VenueOrderId::from("V-PARTIAL-001");
let order_report = create_order_status_report(
Some(ClientOrderId::from("O-PARTIAL-001")),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("2.000"),
Quantity::from("2.000"),
)
.with_avg_px(dec!(3100.00));
mass_status.add_order_reports(vec![order_report]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-PARTIAL-001"),
OrderSide::Buy,
Quantity::from("2.000"),
Price::from("3100.00"),
Money::from("1.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert!(
fill_events.len() >= 2,
"Expected at least 2 fills (synthetic + original), was {}",
fill_events.len()
);
let total_qty: f64 = fill_events.iter().map(|f| f.last_qty.as_f64()).sum();
assert!(
(total_qty - 5.0).abs() < 0.001,
"Total filled qty should be ~5.0 to match position, was {total_qty}"
);
}
#[tokio::test]
async fn test_external_order_has_venue_tag() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let venue_order_id = VenueOrderId::from("V-EXT-001");
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(!result.events.is_empty());
let client_order_id = ClientOrderId::from("V-EXT-001");
let order = ctx.get_order(&client_order_id).expect("Order should exist");
let tags = order.tags().expect("Order should have tags");
assert!(
tags.contains(&ustr::Ustr::from("VENUE")),
"External order should have VENUE tag"
);
}
#[tokio::test]
async fn test_external_order_with_fills_but_no_avg_px_applies_real_fills_only() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let venue_order_id = VenueOrderId::from("V-FILLS-001");
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = OrderStatusReport::new(
test_account_id(),
instrument_id,
None, venue_order_id,
OrderSide::Buy,
OrderType::Market,
TimeInForce::Gtc,
OrderStatus::Filled,
Quantity::from("10.0"),
Quantity::from("10.0"),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None, );
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-REAL-001"),
OrderSide::Buy,
Quantity::from("10.0"),
Price::from("3000.00"),
Money::from("1.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
);
mass_status.add_order_reports(vec![report]);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let accepted_count = result
.events
.iter()
.filter(|e| matches!(e, OrderEventAny::Accepted(_)))
.count();
assert_eq!(accepted_count, 1, "Should have exactly 1 Accepted event");
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert_eq!(
fill_events.len(),
1,
"Should have exactly 1 fill (the real one)"
);
assert_eq!(
fill_events[0].trade_id,
TradeId::from("T-REAL-001"),
"Fill should be the real fill, not an inferred one"
);
assert_eq!(fill_events[0].last_qty, Quantity::from("10.0"));
let client_order_id = ClientOrderId::from("V-FILLS-001");
let order = ctx.get_order(&client_order_id).expect("Order should exist");
assert_eq!(order.status(), OrderStatus::Filled);
assert_eq!(order.filled_qty(), Quantity::from("10.0"));
}
#[tokio::test]
async fn test_position_reconciliation_order_has_reconciliation_tag() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.50)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(!result.events.is_empty());
if let OrderEventAny::Accepted(accepted) = &result.events[0] {
let order = ctx
.get_order(&accepted.client_order_id)
.expect("Order should exist");
let tags = order.tags().expect("Order should have tags");
assert!(
tags.contains(&ustr::Ustr::from("RECONCILIATION")),
"Position reconciliation order should have RECONCILIATION tag, was {tags:?}",
);
} else {
panic!("Expected Accepted event, was {:?}", result.events[0]);
}
}
#[tokio::test]
async fn test_closed_reconciliation_orders_skipped_on_restart() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-RECON-001");
let venue_order_id = VenueOrderId::from("V-RECON-001");
let mut order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("5.0"))
.client_order_id(client_order_id)
.tags(vec![ustr::Ustr::from("RECONCILIATION")])
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
let filled = TestOrderEventStubs::filled(
&order,
&test_instrument(),
None,
None,
None,
None,
None,
None,
None,
None,
);
order.apply(filled).unwrap();
assert!(order.is_closed());
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("5.0"),
Quantity::from("5.0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
result.events.is_empty(),
"Should skip closed RECONCILIATION order, but got {} events",
result.events.len()
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_cross_zero_unbuildable_open_leg_has_no_side_effects() {
let config = ExecutionManagerConfig {
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position_id = PositionId::from("P-CROSS-ZERO-ATOMIC");
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
let strategy_id = StrategyId::from("CROSS-ZERO-ATOMIC");
let venue_ts_last = UnixNanos::from(1_000_000);
ctx.add_instrument(instrument.clone());
ctx.add_position(&position);
ctx.manager
.claim_external_orders(instrument_id, strategy_id)
.unwrap();
let topic = switchboard::get_event_order_topic(strategy_id);
let (handler, event_messages): (_, TypedMessageSavingHandler<OrderEventAny>) =
get_typed_message_saving_handler(None);
msgbus::subscribe_order_events(topic.into(), handler.clone(), None);
let mut report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
venue_ts_last,
venue_ts_last,
None,
None,
Some(dec!(3100.00)),
);
report.signed_decimal_qty = -dec!(10000000000000000000);
let client = MockPositionExecutionClient::new(vec![], vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&client];
let order_count_before = ctx
.cache
.borrow()
.orders_total_count(None, None, None, None, None);
let events = ctx.manager.check_positions_consistency(&clients).await;
msgbus::unsubscribe_order_events(topic.into(), &handler);
let close_venue_order_id = create_position_reconciliation_venue_order_id(
test_account_id(),
instrument_id,
OrderSide::Sell,
OrderType::Market,
Quantity::from("5.0"),
Price::from_decimal_dp(dec!(3000.00), instrument.price_precision()).ok(),
None,
Some("CLOSE"),
venue_ts_last,
);
let close_client_order_id = ClientOrderId::from(close_venue_order_id.as_str());
let cache = ctx.cache.borrow();
let cached_qty = cache
.position(&position_id)
.expect("cached position should remain present")
.signed_decimal_qty();
assert!(events.is_empty());
assert_eq!(
cache.orders_total_count(None, None, None, None, None),
order_count_before,
"an unbuildable open leg must not cache the close order",
);
assert!(
cache.client_order_id(&close_venue_order_id).is_none(),
"an unbuildable open leg must not add the venue-to-client order index",
);
assert!(
cache.venue_order_id(&close_client_order_id).is_none(),
"an unbuildable open leg must not add the client-to-venue order index",
);
assert_eq!(cached_qty, dec!(5.0));
assert!(
event_messages.get_messages().is_empty(),
"an unbuildable open leg must not publish any order event",
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_cross_zero_unbuildable_open_leg_retries_without_poisoning_order_id() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let key = (instrument_id, test_account_id());
let position = create_test_position(
&instrument,
PositionId::from("P-CROSS-ZERO-RETRY"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let venue_ts_last = UnixNanos::from(1_000_000);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mut unbuildable_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
venue_ts_last,
venue_ts_last,
None,
None,
Some(dec!(3100.00)),
);
unbuildable_report.signed_decimal_qty = -dec!(10000000000000000000);
let unbuildable_client = MockPositionExecutionClient::new(vec![], vec![unbuildable_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&unbuildable_client];
let first_events = ctx.manager.check_positions_consistency(&clients).await;
assert!(first_events.is_empty());
assert_eq!(ctx.manager.position_recon_retry_count(&key), 1);
let valid_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
venue_ts_last,
venue_ts_last,
None,
None,
Some(dec!(3100.00)),
);
let valid_client = MockPositionExecutionClient::new(vec![], vec![valid_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&valid_client];
let second_events = ctx.manager.check_positions_consistency(&clients).await;
let fills: Vec<_> = second_events
.iter()
.filter_map(|event| match event {
OrderEventAny::Filled(fill) => Some(fill),
_ => None,
})
.collect();
assert_eq!(ctx.manager.position_recon_retry_count(&key), 0);
assert_eq!(
fills.len(),
2,
"the retry must rebuild both cross-zero legs"
);
assert_eq!(fills[0].order_side, OrderSide::Sell);
assert_eq!(fills[0].last_qty, Quantity::from("5.0"));
assert_eq!(fills[1].order_side, OrderSide::Sell);
assert_eq!(fills[1].last_qty, Quantity::from("3.0"));
for event in &second_events {
ctx.exec_engine.borrow_mut().process(event);
}
let cached_signed_qty = ctx
.cache
.borrow()
.positions_open(
None,
Some(&instrument_id),
None,
Some(&test_account_id()),
None,
)
.iter()
.map(|position| position.signed_decimal_qty())
.sum::<Decimal>();
assert_eq!(cached_signed_qty, dec!(-3.0));
}
#[tokio::test]
async fn test_netting_position_cross_zero_long_to_short() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::new("P-001"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None, Some(dec!(3100.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert_eq!(
fill_events.len(),
2,
"Cross-zero should generate 2 fills (close + open), was {}",
fill_events.len()
);
assert_eq!(fill_events[0].order_side, OrderSide::Sell);
assert_eq!(fill_events[0].last_qty, Quantity::from("5.0"));
assert_eq!(fill_events[1].order_side, OrderSide::Sell);
assert_eq!(fill_events[1].last_qty, Quantity::from("3.0"));
assert_ne!(fill_events[0].venue_order_id, fill_events[1].venue_order_id);
}
#[tokio::test]
async fn test_netting_position_cross_zero_short_to_long() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::new("P-001"),
OrderSide::Sell,
"4.0",
"3000.00",
);
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("2.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None, Some(dec!(2900.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert_eq!(
fill_events.len(),
2,
"Cross-zero should generate 2 fills (close + open), was {}",
fill_events.len()
);
assert_eq!(fill_events[0].order_side, OrderSide::Buy);
assert_eq!(fill_events[0].last_qty, Quantity::from("4.0"));
assert_eq!(fill_events[1].order_side, OrderSide::Buy);
assert_eq!(fill_events[1].last_qty, Quantity::from("2.0"));
assert_ne!(fill_events[0].venue_order_id, fill_events[1].venue_order_id);
}
#[tokio::test]
async fn test_netting_position_flat_report_closes_cached_position() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::new("P-001"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Flat,
Quantity::from("0.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None, None, );
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert_eq!(
fill_events.len(),
1,
"Flat report should generate 1 closing fill, was {}",
fill_events.len()
);
assert_eq!(fill_events[0].order_side, OrderSide::Sell);
assert_eq!(fill_events[0].last_qty, Quantity::from("5.0"));
}
#[tokio::test]
async fn test_expired_order_applies_fills_before_terminal_event() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let client_order_id = ClientOrderId::from("O-EXPIRE-TEST");
let venue_order_id = VenueOrderId::from("V-EXPIRE-001");
let mut order = OrderTestBuilder::new(OrderType::Limit)
.instrument_id(instrument_id)
.side(OrderSide::Buy)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.client_order_id(client_order_id)
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order.clone());
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::Expired,
Quantity::from("10.0"),
Quantity::from("3.0"), );
mass_status.add_order_reports(vec![report]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("3.0"),
Price::from("100.0"),
Money::from("0.10 USDT"),
LiquiditySide::Maker,
Some(client_order_id),
None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_count = result
.events
.iter()
.filter(|e| matches!(e, OrderEventAny::Filled(_)))
.count();
let expired_count = result
.events
.iter()
.filter(|e| matches!(e, OrderEventAny::Expired(_)))
.count();
assert_eq!(fill_count, 1, "Should have 1 fill event");
assert_eq!(expired_count, 1, "Should have 1 expired event");
let fill_idx = result
.events
.iter()
.position(|e| matches!(e, OrderEventAny::Filled(_)))
.unwrap();
let expired_idx = result
.events
.iter()
.position(|e| matches!(e, OrderEventAny::Expired(_)))
.unwrap();
assert!(
fill_idx < expired_idx,
"Fill event should come before Expired event"
);
}
#[tokio::test]
async fn test_partial_window_adjustment_skips_hedge_mode_instruments() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let venue_order_id = VenueOrderId::from("V-HEDGE-001");
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("5.0"),
Quantity::from("5.0"),
);
mass_status.add_order_reports(vec![report]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-HEDGE-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("100.0"),
Money::from("0.10 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
);
mass_status.add_fill_reports(vec![fill]);
let hedge_position_id = PositionId::new("HEDGE-POS-001");
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None, Some(hedge_position_id), Some(dec!(100.0)), );
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert!(
!fill_events.is_empty(),
"Fill events should be preserved for hedge mode instruments"
);
assert_eq!(
fill_events[0].last_qty,
Quantity::from("5.0"),
"Fill quantity should match original fill report"
);
}
#[tokio::test]
async fn test_adjust_fills_multi_instrument_preserves_all_fills() {
let mut ctx = TestContext::new();
let instrument_id1 = test_instrument_id();
let instrument_id2 = test_instrument_id2();
ctx.add_instrument(test_instrument());
ctx.add_instrument(test_instrument2());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let venue_order_id1a = VenueOrderId::from("V-ETH-001");
let venue_order_id1b = VenueOrderId::from("V-ETH-002");
let order_report1a = create_order_status_report(
Some(ClientOrderId::from("O-ETH-001")),
venue_order_id1a,
instrument_id1,
OrderStatus::Filled,
Quantity::from("1.000"),
Quantity::from("1.000"),
);
let order_report1b = create_order_status_report(
Some(ClientOrderId::from("O-ETH-002")),
venue_order_id1b,
instrument_id1,
OrderStatus::Filled,
Quantity::from("1.000"),
Quantity::from("1.000"),
);
let fill1a = FillReport::new(
test_account_id(),
instrument_id1,
venue_order_id1a,
TradeId::from("T-ETH-001"),
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
);
let fill1b = FillReport::new(
test_account_id(),
instrument_id1,
venue_order_id1b,
TradeId::from("T-ETH-002"),
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3100.00"),
Money::from("0.50 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_002),
UnixNanos::from(1_000_002),
None,
);
let position_report1 = PositionStatusReport::new(
test_account_id(),
instrument_id1,
PositionSideSpecified::Long,
Quantity::from("2.000"),
UnixNanos::from(2_000),
UnixNanos::from(2_000),
None,
None,
Some(dec!(3050.00)),
);
let venue_order_id2a = VenueOrderId::from("V-BTC-001");
let venue_order_id2b = VenueOrderId::from("V-BTC-002");
let order_report2a = create_order_status_report(
Some(ClientOrderId::from("O-BTC-001")),
venue_order_id2a,
instrument_id2,
OrderStatus::Filled,
Quantity::from("50"),
Quantity::from("50"),
);
let order_report2b = create_order_status_report(
Some(ClientOrderId::from("O-BTC-002")),
venue_order_id2b,
instrument_id2,
OrderStatus::Filled,
Quantity::from("50"),
Quantity::from("50"),
);
let fill2a = FillReport::new(
test_account_id(),
instrument_id2,
venue_order_id2a,
TradeId::from("T-BTC-001"),
OrderSide::Buy,
Quantity::from("50"),
Price::from("50000.0"),
Money::from("0.001 BTC"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_003),
UnixNanos::from(1_000_003),
None,
);
let fill2b = FillReport::new(
test_account_id(),
instrument_id2,
venue_order_id2b,
TradeId::from("T-BTC-002"),
OrderSide::Buy,
Quantity::from("50"),
Price::from("51000.0"),
Money::from("0.001 BTC"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000_004),
UnixNanos::from(1_000_004),
None,
);
let position_report2 = PositionStatusReport::new(
test_account_id(),
instrument_id2,
PositionSideSpecified::Long,
Quantity::from("100"),
UnixNanos::from(2_500),
UnixNanos::from(2_500),
None,
None,
Some(dec!(50500.0)),
);
mass_status.add_order_reports(vec![
order_report1a,
order_report1b,
order_report2a,
order_report2b,
]);
mass_status.add_fill_reports(vec![fill1a, fill1b, fill2a, fill2b]);
mass_status.add_position_reports(vec![position_report1, position_report2]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert_eq!(
fill_events.len(),
4,
"Expected 4 fill events (2 per instrument)"
);
let eth_fills: Vec<_> = fill_events
.iter()
.filter(|f| f.instrument_id == instrument_id1)
.collect();
assert_eq!(eth_fills.len(), 2, "Expected 2 fills for ETHUSDT");
let eth_total_qty: f64 = eth_fills.iter().map(|f| f.last_qty.as_f64()).sum();
assert!(
(eth_total_qty - 2.0).abs() < 0.001,
"ETHUSDT total qty should be 2.0, was {eth_total_qty}"
);
let btc_fills: Vec<_> = fill_events
.iter()
.filter(|f| f.instrument_id == instrument_id2)
.collect();
assert_eq!(btc_fills.len(), 2, "Expected 2 fills for XBTUSD");
let btc_total_qty: f64 = btc_fills.iter().map(|f| f.last_qty.as_f64()).sum();
assert!(
(btc_total_qty - 100.0).abs() < 0.001,
"XBTUSD total qty should be 100.0, was {btc_total_qty}"
);
}
#[tokio::test]
async fn test_mass_status_preserves_symbol_scoped_trade_ids() {
let mut ctx = TestContext::new();
let instrument1 = test_instrument();
let instrument2 = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
let instrument_id1 = instrument1.id();
let instrument_id2 = instrument2.id();
let client_order_id1 = ClientOrderId::from("O-SCOPED-ETH");
let client_order_id2 = ClientOrderId::from("O-SCOPED-BTC");
let venue_order_id1 = VenueOrderId::from("V-SCOPED-ETH");
let venue_order_id2 = VenueOrderId::from("V-SCOPED-BTC");
ctx.add_instrument(instrument1);
ctx.add_instrument(instrument2);
ctx.add_order(create_accepted_order(
client_order_id1.as_str(),
instrument_id1,
OrderSide::Buy,
"1.000",
"3000.00",
venue_order_id1,
));
ctx.add_order(create_accepted_order(
client_order_id2.as_str(),
instrument_id2,
OrderSide::Buy,
"0.100000",
"50000.00",
venue_order_id2,
));
let shared_trade_id = TradeId::from("12345678");
let fill1 = FillReport::new(
test_account_id(),
instrument_id1,
venue_order_id1,
shared_trade_id,
OrderSide::Buy,
Quantity::from("1.000"),
Price::from("3000.00"),
Money::from("0.10 USDT"),
LiquiditySide::Taker,
Some(client_order_id1),
None,
UnixNanos::from(1_000_001),
UnixNanos::from(1_000_001),
None,
);
let fill2 = FillReport::new(
test_account_id(),
instrument_id2,
venue_order_id2,
shared_trade_id,
OrderSide::Buy,
Quantity::from("0.100000"),
Price::from("50000.00"),
Money::from("0.10 USDT"),
LiquiditySide::Taker,
Some(client_order_id2),
None,
UnixNanos::from(1_000_002),
UnixNanos::from(1_000_002),
None,
);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
mass_status.add_fill_reports(vec![fill1, fill2]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_instruments: HashSet<_> = result
.events
.iter()
.filter_map(|event| match event {
OrderEventAny::Filled(fill) => Some(fill.instrument_id),
_ => None,
})
.collect();
assert_eq!(
fill_instruments,
HashSet::from([instrument_id1, instrument_id2])
);
}
#[tokio::test]
async fn test_adjust_fills_missing_order_reports_uses_fill_side() {
let instrument_id = test_instrument_id();
let instrument = test_instrument();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let venue_order_id1 = VenueOrderId::from("V-NO-REPORT-001");
let fill1 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id1,
TradeId::from("T-001"),
OrderSide::Buy, Quantity::from("0.020"),
Price::from("4000.00"),
Money::from("0.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("0.050"),
UnixNanos::from(2_000),
UnixNanos::from(2_000),
None,
None,
Some(dec!(3900.00)),
);
mass_status.add_fill_reports(vec![fill1]);
mass_status.add_position_reports(vec![position_report]);
let result = process_mass_status_for_reconciliation(&mass_status, &instrument, None).unwrap();
assert!(
!result.orders.is_empty(),
"Synthetic order should be created"
);
assert!(!result.fills.is_empty(), "Fills should be present");
for order in result.orders.values() {
assert_eq!(
order.order_side,
OrderSide::Buy,
"Synthetic order side should be BUY (inferred from fill.order_side)"
);
}
}
#[tokio::test]
async fn test_adjust_fills_filter_to_current_lifecycle_preserves_working_orders() {
let instrument_id = test_instrument_id();
let instrument = test_instrument();
let ts_now: u64 = 1_000_000_000_000;
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("200"),
UnixNanos::from(ts_now),
UnixNanos::from(ts_now),
None,
None,
Some(dec!(1.1000)),
);
let venue_order_id1 = VenueOrderId::from("V-001");
let order_o1 = create_order_status_report(
Some(ClientOrderId::from("C-001")),
venue_order_id1,
instrument_id,
OrderStatus::Filled,
Quantity::from("100"),
Quantity::from("100"),
);
let fill_o1 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id1,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("100"),
Price::from("1.0900"),
Money::from("0.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(ts_now - 3_000_000_000),
UnixNanos::from(ts_now - 3_000_000_000),
None,
);
let venue_order_id2 = VenueOrderId::from("V-002");
let order_o2 = OrderStatusReport::new(
test_account_id(),
instrument_id,
Some(ClientOrderId::from("C-002")),
venue_order_id2,
OrderSide::Sell,
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Filled,
Quantity::from("100"),
Quantity::from("100"),
UnixNanos::from(ts_now - 2_000_000_000),
UnixNanos::from(ts_now - 2_000_000_000),
UnixNanos::from(ts_now - 2_000_000_000),
None,
);
let fill_o2 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id2,
TradeId::from("T-002"),
OrderSide::Sell,
Quantity::from("100"),
Price::from("1.0950"),
Money::from("0.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(ts_now - 2_000_000_000), UnixNanos::from(ts_now - 2_000_000_000),
None,
);
let venue_order_id3 = VenueOrderId::from("V-003");
let order_o3 = create_order_status_report(
Some(ClientOrderId::from("C-003")),
venue_order_id3,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("300"),
Quantity::from("200"),
);
let fill_o3 = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id3,
TradeId::from("T-003"),
OrderSide::Buy,
Quantity::from("200"),
Price::from("1.1000"),
Money::from("0.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(ts_now),
UnixNanos::from(ts_now),
None,
);
mass_status.add_order_reports(vec![order_o1, order_o2, order_o3]);
mass_status.add_fill_reports(vec![fill_o1, fill_o2, fill_o3]);
mass_status.add_position_reports(vec![position_report]);
let result = process_mass_status_for_reconciliation(&mass_status, &instrument, None).unwrap();
assert!(
!result.orders.contains_key(&venue_order_id1),
"O1 should be filtered (closed order from previous lifecycle)"
);
assert!(
!result.orders.contains_key(&venue_order_id2),
"O2 should be filtered (closed order from previous lifecycle)"
);
assert!(
result.orders.contains_key(&venue_order_id3),
"O3 should be preserved (working order)"
);
assert_eq!(
result.orders[&venue_order_id3].order_status,
OrderStatus::PartiallyFilled
);
assert!(
result.fills.contains_key(&venue_order_id3),
"O3 fills should be present"
);
assert_eq!(result.fills.len(), 1, "Only O3 fills should remain");
}
#[tokio::test]
async fn test_cross_zero_with_missing_cached_avg_px_returns_none() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::new("P-001"),
OrderSide::Buy,
"5.0",
"0.00", );
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3100.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
result.events.len() <= 4,
"Should not produce excessive events"
);
}
#[tokio::test]
async fn test_cross_zero_with_missing_venue_avg_px_closes_only() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::new("P-001"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&position);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
None, );
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert_eq!(
fill_events.len(),
1,
"Should generate only close fill when venue avg_px missing, was {}",
fill_events.len()
);
assert_eq!(fill_events[0].order_side, OrderSide::Sell);
assert_eq!(fill_events[0].last_qty, Quantity::from("5.0"));
}
#[tokio::test]
async fn test_hedge_mode_multiple_positions_same_instrument() {
let mut ctx = TestContext::new();
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let long_position = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("10.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::new("HEDGE-LONG-001")), Some(dec!(3000.00)),
);
let short_position = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::new("HEDGE-SHORT-001")),
Some(dec!(3100.00)),
);
mass_status.add_position_reports(vec![long_position, short_position]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let fill_events: Vec<_> = result
.events
.iter()
.filter_map(|e| {
if let OrderEventAny::Filled(f) = e {
Some(f)
} else {
None
}
})
.collect();
assert!(
fill_events.len() >= 2,
"Should process both hedge positions, was {} fills",
fill_events.len()
);
let has_buy = fill_events.iter().any(|f| f.order_side == OrderSide::Buy);
let has_sell = fill_events.iter().any(|f| f.order_side == OrderSide::Sell);
assert!(has_buy, "Should have BUY fill for long position");
assert!(has_sell, "Should have SELL fill for short position");
}
#[tokio::test]
async fn test_hedge_mode_with_filter_unclaimed_external_allows_synthetic() {
let config = ExecutionManagerConfig {
filter_unclaimed_external: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = test_instrument_id();
ctx.add_instrument(instrument.clone());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("10.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::new("HEDGE-001")),
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result.events.is_empty(),
"Synthetic orders should bypass filter_unclaimed_external"
);
}
#[tokio::test]
async fn test_duplicate_order_reports_keeps_most_advanced_state() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let venue_order_id = VenueOrderId::from("V-DUP-001");
let client_order_id = ClientOrderId::from("O-DUP-001");
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("3000.00"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report_partial = OrderStatusReport::new(
test_account_id(),
instrument_id,
Some(client_order_id),
venue_order_id,
OrderSide::Buy,
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
let report_filled = OrderStatusReport::new(
test_account_id(),
instrument_id,
Some(client_order_id),
venue_order_id,
OrderSide::Buy,
OrderType::Limit,
TimeInForce::Gtc,
OrderStatus::Filled,
Quantity::from("10.0"),
Quantity::from("10.0"),
UnixNanos::from(2_000),
UnixNanos::from(2_000),
UnixNanos::from(2_000),
None,
)
.with_avg_px(dec!(3000.0));
mass_status.add_order_reports(vec![report_partial, report_filled]);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("10.0"),
Price::from("3000.00"),
Money::from("1.00 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(2_000),
UnixNanos::from(2_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let _result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let order = ctx.get_order(&client_order_id).expect("Order should exist");
assert_eq!(
order.status(),
OrderStatus::Filled,
"Order should be in most advanced state (Filled)"
);
}
#[tokio::test]
async fn test_reconciliation_order_skipped_on_restart() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let venue_order_id = VenueOrderId::from("S-RECON-001");
let client_order_id = ClientOrderId::from("S-RECON-001");
let mut order = OrderTestBuilder::new(OrderType::Market)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("5.0"))
.tags(vec![ustr::Ustr::from("RECONCILIATION")])
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
let filled = TestOrderEventStubs::filled(
&order,
&test_instrument(),
None,
None,
Some(Price::from("3000.00")),
None,
None,
None,
None,
None,
);
order.apply(filled).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = OrderStatusReport::new(
test_account_id(),
instrument_id,
Some(client_order_id),
venue_order_id,
OrderSide::Buy,
OrderType::Market,
TimeInForce::Gtc,
OrderStatus::Filled,
Quantity::from("5.0"),
Quantity::from("5.0"),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
)
.with_avg_px(dec!(3000.0));
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
result.events.is_empty(),
"Closed reconciliation order should be skipped on restart"
);
}
#[tokio::test]
async fn test_partially_filled_order_has_fills_applied() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let venue_order_id = VenueOrderId::from("V-PARTIAL-001");
let client_order_id = ClientOrderId::from("O-PARTIAL-001");
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("3000.00"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"),
)
.with_avg_px(dec!(3000.0));
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("3000.00"),
Money::from("0.50 USDT"),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
mass_status.add_order_reports(vec![report]);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let has_fills = result
.events
.iter()
.any(|e| matches!(e, OrderEventAny::Filled(_)));
assert!(has_fills, "Should generate fill events");
let order = ctx.get_order(&client_order_id).expect("Order should exist");
assert!(
order.filled_qty() >= Quantity::from("5.0"),
"Order should have at least 5.0 filled"
);
}
#[tokio::test]
async fn test_working_order_with_new_fills_updates_correctly() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let venue_order_id = VenueOrderId::from("V-WORKING-001");
let client_order_id = ClientOrderId::from("O-WORKING-001");
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("3000.00"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
let first_fill = TestOrderEventStubs::filled(
&order,
&test_instrument(),
Some(TradeId::from("T-PREV-001")),
None,
Some(Price::from("3000.00")),
Some(Quantity::from("3.0")),
None,
None,
None,
None,
);
order.apply(first_fill).unwrap();
ctx.add_order(order);
ctx.cache
.borrow_mut()
.add_venue_order_id(&client_order_id, &venue_order_id, false)
.unwrap();
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("7.0"),
)
.with_avg_px(dec!(3000.0));
let new_fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-NEW-001"),
OrderSide::Buy,
Quantity::from("4.0"),
Price::from("3000.00"),
Money::from("0.40 USDT"),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(2_000),
UnixNanos::from(2_000),
None,
);
mass_status.add_order_reports(vec![report]);
mass_status.add_fill_reports(vec![new_fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let has_fills = result
.events
.iter()
.any(|e| matches!(e, OrderEventAny::Filled(_)));
assert!(has_fills, "Should generate fill events");
let order = ctx.get_order(&client_order_id).expect("Order should exist");
assert_eq!(
order.filled_qty(),
Quantity::from("7.0"),
"Order should have updated filled qty"
);
}
#[tokio::test]
async fn test_orphan_fills_without_order_reports_processed() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let orphan_venue_order_id = VenueOrderId::from("V-ORPHAN-001");
let orphan_fill = FillReport::new(
test_account_id(),
instrument_id,
orphan_venue_order_id,
TradeId::from("T-ORPHAN-001"),
OrderSide::Buy,
Quantity::from("2.0"),
Price::from("3000.00"),
Money::from("0.20 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("2.0"),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
None,
Some(dec!(3000.00)),
);
mass_status.add_fill_reports(vec![orphan_fill]);
mass_status.add_position_reports(vec![position_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result.events.is_empty()
|| !ctx
.cache
.borrow()
.positions(None, None, None, None, None)
.is_empty(),
"Orphan fills should be processed in some way"
);
}
#[tokio::test]
async fn test_orphan_fills_for_unknown_instrument_skipped() {
let mut ctx = TestContext::new();
ctx.add_instrument(test_instrument());
let unknown_instrument_id = InstrumentId::from("UNKNOWN-PERP.BINANCE");
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let orphan_fill = FillReport::new(
test_account_id(),
unknown_instrument_id,
VenueOrderId::from("V-UNKNOWN-001"),
TradeId::from("T-UNKNOWN-001"),
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("100.00"),
Money::from("0.10 USDT"),
LiquiditySide::Taker,
None,
None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
mass_status.add_fill_reports(vec![orphan_fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let has_unknown_fills = result.events.iter().any(|e| {
if let OrderEventAny::Filled(f) = e {
f.instrument_id == unknown_instrument_id
} else {
false
}
});
assert!(
!has_unknown_fills,
"Fills for unknown instruments should be skipped"
);
}
#[tokio::test]
async fn test_filtered_client_order_ids_skips_matching_orders() {
let filtered_id = ClientOrderId::from("O-FILTERED-001");
let config = ExecutionManagerConfig {
filtered_client_order_ids: IndexSet::from([filtered_id]),
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let report = create_order_status_report(
Some(filtered_id),
VenueOrderId::from("V-FILTERED-001"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
result.events.is_empty(),
"Filtered order should not generate events"
);
assert!(
ctx.get_order(&filtered_id).is_none(),
"Filtered order should not be added to cache"
);
}
#[tokio::test]
async fn test_filtered_client_order_ids_skips_orphan_fills() {
let filtered_id = ClientOrderId::from("O-FILTERED-002");
let venue_order_id = VenueOrderId::from("V-FILTERED-002");
let config = ExecutionManagerConfig {
filtered_client_order_ids: IndexSet::from([filtered_id]),
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(filtered_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-001"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("100.00"),
Money::from("0.50 USD"),
LiquiditySide::Taker,
Some(filtered_id),
None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result
.events
.iter()
.any(|e| matches!(e, OrderEventAny::Filled(_))),
"Filtered order should not receive orphan fill events"
);
}
#[tokio::test]
async fn test_filtered_client_order_ids_skips_orphan_fills_via_venue_order_id_lookup() {
let filtered_id = ClientOrderId::from("O-FILTERED-003");
let venue_order_id = VenueOrderId::from("V-FILTERED-003");
let config = ExecutionManagerConfig {
filtered_client_order_ids: IndexSet::from([filtered_id]),
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(filtered_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let fill = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-002"),
OrderSide::Buy,
Quantity::from("5.0"),
Price::from("100.00"),
Money::from("0.50 USD"),
LiquiditySide::Taker,
None, None,
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
);
mass_status.add_fill_reports(vec![fill]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(
!result
.events
.iter()
.any(|e| matches!(e, OrderEventAny::Filled(_))),
"Filtered order should not receive orphan fill events via venue_order_id lookup"
);
}
#[tokio::test]
async fn test_reconciliation_instrument_ids_filters_other_instruments() {
let included_instrument = test_instrument_id();
let config = ExecutionManagerConfig {
reconciliation_instrument_ids: IndexSet::from([included_instrument]),
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let excluded_instrument = test_instrument2();
let excluded_instrument_id = test_instrument_id2();
ctx.add_instrument(excluded_instrument.clone());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let included_report = create_order_status_report(
Some(ClientOrderId::from("O-INCLUDED-001")),
VenueOrderId::from("V-INCLUDED-001"),
included_instrument,
OrderStatus::Accepted,
Quantity::from("5.0"),
Quantity::from("0.0"),
);
let excluded_report = create_order_status_report(
Some(ClientOrderId::from("O-EXCLUDED-001")),
VenueOrderId::from("V-EXCLUDED-001"),
excluded_instrument_id,
OrderStatus::Accepted,
Quantity::from("5.0"),
Quantity::from("0.0"),
);
mass_status.add_order_reports(vec![included_report, excluded_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let included_order = ctx.get_order(&ClientOrderId::from("O-INCLUDED-001"));
let excluded_order = ctx.get_order(&ClientOrderId::from("O-EXCLUDED-001"));
assert!(
included_order.is_some(),
"Included instrument order should be reconciled"
);
assert!(
excluded_order.is_none(),
"Excluded instrument order should NOT be reconciled"
);
let has_excluded_events = result.events.iter().any(|e| match e {
OrderEventAny::Initialized(init) => init.instrument_id == excluded_instrument_id,
OrderEventAny::Accepted(acc) => acc.instrument_id == excluded_instrument_id,
_ => false,
});
assert!(
!has_excluded_events,
"No events should be generated for excluded instrument"
);
}
#[tokio::test]
async fn test_reconciliation_instrument_ids_filters_position_reports() {
let included_instrument_id = test_instrument_id();
let config = ExecutionManagerConfig {
reconciliation_instrument_ids: IndexSet::from([included_instrument_id]),
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let excluded_instrument = test_instrument2();
let excluded_instrument_id = test_instrument_id2();
ctx.add_instrument(excluded_instrument.clone());
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let position_report = PositionStatusReport::new(
test_account_id(),
excluded_instrument_id,
PositionSideSpecified::Long,
Quantity::from("10.0"),
UnixNanos::from(1_000),
UnixNanos::from(1_000),
None,
None,
Some(dec!(100.00)),
);
mass_status.add_position_reports(vec![position_report]);
let _result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
let cache = ctx.cache.borrow();
let positions = cache.positions(None, None, None, None, None);
let has_excluded_position = positions
.iter()
.any(|p| p.instrument_id == excluded_instrument_id);
assert!(
!has_excluded_position,
"No position should be created for excluded instrument"
);
}
struct MockExecutionClient {
client_id: ClientId,
account_id: AccountId,
venue: Venue,
handled_venues: Option<IndexSet<Venue>>,
order_report: RefCell<Option<OrderStatusReport>>,
order_reports: RefCell<Vec<OrderStatusReport>>,
order_report_query_count: Cell<usize>,
fail_order_report: bool,
fail_order_reports: bool,
on_order_report_query: RefCell<Option<Box<dyn FnOnce()>>>,
on_order_reports_query: RefCell<Option<Box<dyn FnOnce()>>>,
}
impl MockExecutionClient {
fn new(order_reports: Vec<OrderStatusReport>) -> Self {
Self {
client_id: test_client_id(),
account_id: test_account_id(),
venue: test_venue(),
handled_venues: None,
order_report: RefCell::new(None),
order_reports: RefCell::new(order_reports),
order_report_query_count: Cell::new(0),
fail_order_report: false,
fail_order_reports: false,
on_order_report_query: RefCell::new(None),
on_order_reports_query: RefCell::new(None),
}
}
fn for_venue(client_id: ClientId, venue: Venue, order_reports: Vec<OrderStatusReport>) -> Self {
Self {
client_id,
account_id: test_account_id(),
venue,
handled_venues: None,
order_report: RefCell::new(None),
order_reports: RefCell::new(order_reports),
order_report_query_count: Cell::new(0),
fail_order_report: false,
fail_order_reports: false,
on_order_report_query: RefCell::new(None),
on_order_reports_query: RefCell::new(None),
}
}
fn failing(client_id: ClientId, venue: Venue) -> Self {
Self {
client_id,
account_id: test_account_id(),
venue,
handled_venues: None,
order_report: RefCell::new(None),
order_reports: RefCell::new(Vec::new()),
order_report_query_count: Cell::new(0),
fail_order_report: false,
fail_order_reports: true,
on_order_report_query: RefCell::new(None),
on_order_reports_query: RefCell::new(None),
}
}
fn with_account_id(mut self, account_id: AccountId) -> Self {
self.account_id = account_id;
self
}
fn with_handled_venues(mut self, handled_venues: IndexSet<Venue>) -> Self {
self.handled_venues = Some(handled_venues);
self
}
fn with_order_report(self, report: OrderStatusReport) -> Self {
*self.order_report.borrow_mut() = Some(report);
self
}
fn with_failed_order_report(mut self) -> Self {
self.fail_order_report = true;
self
}
fn with_on_order_report_query(self, callback: Box<dyn FnOnce()>) -> Self {
*self.on_order_report_query.borrow_mut() = Some(callback);
self
}
fn with_on_order_reports_query(self, callback: Box<dyn FnOnce()>) -> Self {
*self.on_order_reports_query.borrow_mut() = Some(callback);
self
}
}
#[async_trait(?Send)]
impl ExecutionClient for MockExecutionClient {
fn is_connected(&self) -> bool {
true
}
fn client_id(&self) -> ClientId {
self.client_id
}
fn account_id(&self) -> AccountId {
self.account_id
}
fn venue(&self) -> Venue {
self.venue
}
fn handles_order_venue(&self, venue: Venue) -> bool {
self.handled_venues
.as_ref()
.map_or(self.venue == venue, |handled| handled.contains(&venue))
}
fn oms_type(&self) -> OmsType {
OmsType::Hedging
}
fn get_account(&self) -> Option<AccountAny> {
None
}
fn generate_account_state(
&self,
_balances: Vec<AccountBalance>,
_margins: Vec<MarginBalance>,
_reported: bool,
_ts_event: UnixNanos,
) -> anyhow::Result<()> {
Ok(())
}
fn start(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn stop(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn submit_order(&self, _cmd: SubmitOrder) -> anyhow::Result<()> {
Ok(())
}
fn submit_order_list(&self, _cmd: SubmitOrderList) -> anyhow::Result<()> {
Ok(())
}
fn modify_order(&self, _cmd: ModifyOrder) -> anyhow::Result<()> {
Ok(())
}
fn cancel_order(&self, _cmd: CancelOrder) -> anyhow::Result<()> {
Ok(())
}
fn cancel_all_orders(&self, _cmd: CancelAllOrders) -> anyhow::Result<()> {
Ok(())
}
fn batch_cancel_orders(&self, _cmd: BatchCancelOrders) -> anyhow::Result<()> {
Ok(())
}
fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
Ok(())
}
fn query_order(&self, _cmd: QueryOrder) -> anyhow::Result<()> {
Ok(())
}
async fn generate_order_status_report(
&self,
_cmd: &GenerateOrderStatusReport,
) -> anyhow::Result<Option<OrderStatusReport>> {
self.order_report_query_count
.set(self.order_report_query_count.get() + 1);
if let Some(callback) = self.on_order_report_query.borrow_mut().take() {
callback();
}
if self.fail_order_report {
anyhow::bail!("order report unavailable");
}
Ok(self.order_report.borrow().clone())
}
async fn generate_order_status_reports(
&self,
_cmd: &GenerateOrderStatusReports,
) -> anyhow::Result<Vec<OrderStatusReport>> {
if let Some(callback) = self.on_order_reports_query.borrow_mut().take() {
callback();
}
if self.fail_order_reports {
anyhow::bail!("order reports unavailable");
}
Ok(self.order_reports.borrow().clone())
}
}
#[rstest]
fn test_check_open_order_queries_builds_query_for_cached_open_order() {
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 5,
..Default::default()
});
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-QUERY-001");
let venue_order_id = VenueOrderId::from("V-QUERY-001");
let client_id = test_client_id();
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, client_id);
let queries = ctx.manager.check_open_order_queries();
assert_eq!(queries.len(), 1);
match &queries[0] {
TradingCommand::QueryOrder(query) => {
assert_eq!(query.client_id, Some(client_id));
assert_eq!(query.client_order_id, client_order_id);
assert_eq!(query.venue_order_id, Some(venue_order_id));
}
command => panic!("Expected QueryOrder, was {command:?}"),
}
}
#[rstest]
fn test_check_open_order_queries_dedupes_open_inflight_order() {
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 5,
single_order_query_delay_ms: 0,
..Default::default()
});
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-QUERY-002");
let venue_order_id = VenueOrderId::from("V-QUERY-002");
let client_id = test_client_id();
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, client_id);
let order = ctx
.cache
.borrow()
.order(&client_order_id)
.map(|order| order.clone())
.unwrap();
let pending = OrderEventAny::PendingCancel(
OrderPendingCancelSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.account_id(test_account_id())
.venue_order_id(venue_order_id)
.build(),
);
ctx.cache.borrow_mut().update_order(&pending).unwrap();
let queries = ctx.manager.check_open_order_queries();
assert_eq!(queries.len(), 1);
match &queries[0] {
TradingCommand::QueryOrder(query) => {
assert_eq!(query.client_id, Some(client_id));
assert_eq!(query.client_order_id, client_order_id);
assert_eq!(query.venue_order_id, Some(venue_order_id));
}
command => panic!("Expected QueryOrder, was {command:?}"),
}
}
#[rstest]
fn test_check_open_order_queries_respects_per_cycle_limit() {
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 1,
..Default::default()
});
ctx.add_instrument(test_instrument());
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-QUERY-003"),
VenueOrderId::from("V-QUERY-003"),
test_client_id(),
);
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-QUERY-004"),
VenueOrderId::from("V-QUERY-004"),
test_client_id(),
);
let queries = ctx.manager.check_open_order_queries();
assert_eq!(queries.len(), 1);
}
#[rstest]
fn test_check_open_order_queries_rotates_after_open_report_response() {
fn queried_client_order_id(queries: &[TradingCommand]) -> ClientOrderId {
match &queries[0] {
TradingCommand::QueryOrder(query) => query.client_order_id,
command => panic!("Expected QueryOrder, was {command:?}"),
}
}
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 1,
open_check_threshold_ns: 0,
..Default::default()
});
ctx.add_instrument(test_instrument());
let first_id = ClientOrderId::from("O-QUERY-011");
let second_id = ClientOrderId::from("O-QUERY-012");
let third_id = ClientOrderId::from("O-QUERY-013");
let first_venue_id = VenueOrderId::from("V-QUERY-011");
let second_venue_id = VenueOrderId::from("V-QUERY-012");
let third_venue_id = VenueOrderId::from("V-QUERY-013");
insert_accepted_limit_order(&ctx, first_id, first_venue_id, test_client_id());
insert_accepted_limit_order(&ctx, second_id, second_venue_id, test_client_id());
insert_accepted_limit_order(&ctx, third_id, third_venue_id, test_client_id());
let first_queries = ctx.manager.check_open_order_queries();
let first_report = create_order_status_report(
Some(first_id),
first_venue_id,
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::from("0.0"),
);
ctx.manager
.observe_execution_report(&ExecutionReport::Order(Box::new(first_report)));
let second_queries = ctx.manager.check_open_order_queries();
let second_report = create_order_status_report(
Some(second_id),
second_venue_id,
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::from("0.0"),
);
ctx.manager
.observe_execution_report(&ExecutionReport::Order(Box::new(second_report)));
let third_queries = ctx.manager.check_open_order_queries();
assert_eq!(first_queries.len(), 1);
assert_eq!(second_queries.len(), 1);
assert_eq!(third_queries.len(), 1);
assert_eq!(queried_client_order_id(&first_queries), first_id);
assert_eq!(queried_client_order_id(&second_queries), second_id);
assert_eq!(queried_client_order_id(&third_queries), third_id);
}
#[rstest]
fn test_check_open_order_queries_returns_empty_when_cycle_limit_is_zero() {
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 0,
..Default::default()
});
ctx.add_instrument(test_instrument());
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-QUERY-005"),
VenueOrderId::from("V-QUERY-005"),
test_client_id(),
);
let queries = ctx.manager.check_open_order_queries();
assert!(queries.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_order_queries_respects_query_delay() {
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 5,
single_order_query_delay_ms: 100,
..Default::default()
});
ctx.add_instrument(test_instrument());
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-QUERY-006"),
VenueOrderId::from("V-QUERY-006"),
test_client_id(),
);
let first_queries = ctx.manager.check_open_order_queries();
let delayed_queries = ctx.manager.check_open_order_queries();
ctx.advance_time(200_000_000);
let domain_advanced_queries = ctx.manager.check_open_order_queries();
advance_clock(dst::time::Duration::from_millis(100)).await;
let monotonic_advanced_queries = ctx.manager.check_open_order_queries();
assert_eq!(first_queries.len(), 1);
assert!(delayed_queries.is_empty());
assert!(domain_advanced_queries.is_empty());
assert_eq!(monotonic_advanced_queries.len(), 1);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_order_queries_defers_with_recent_local_activity() {
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 5,
open_check_threshold_ns: 5_000_000_000,
..Default::default()
});
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-QUERY-007");
insert_accepted_limit_order(
&ctx,
client_order_id,
VenueOrderId::from("V-QUERY-007"),
test_client_id(),
);
ctx.manager.record_local_activity(client_order_id);
let queries = ctx.manager.check_open_order_queries();
ctx.advance_time(6_000_000_000);
let domain_advanced_queries = ctx.manager.check_open_order_queries();
advance_clock(dst::time::Duration::from_secs(5)).await;
let monotonic_advanced_queries = ctx.manager.check_open_order_queries();
assert!(queries.is_empty());
assert!(domain_advanced_queries.is_empty());
assert_eq!(monotonic_advanced_queries.len(), 1);
}
#[rstest]
fn test_check_open_order_queries_skips_filtered_client_order_ids() {
let filtered_id = ClientOrderId::from("O-QUERY-008");
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 5,
filtered_client_order_ids: IndexSet::from([filtered_id]),
..Default::default()
});
ctx.add_instrument(test_instrument());
insert_accepted_limit_order(
&ctx,
filtered_id,
VenueOrderId::from("V-QUERY-008"),
test_client_id(),
);
let queries = ctx.manager.check_open_order_queries();
assert!(queries.is_empty());
}
#[rstest]
fn test_check_open_order_queries_filters_reconciliation_instruments() {
let included_id = ClientOrderId::from("O-QUERY-009");
let excluded_id = ClientOrderId::from("O-QUERY-010");
let mut ctx = TestContext::with_config(ExecutionManagerConfig {
max_single_order_queries_per_cycle: 5,
reconciliation_instrument_ids: IndexSet::from([test_instrument_id()]),
..Default::default()
});
ctx.add_instrument(test_instrument());
ctx.add_instrument(test_instrument2());
insert_accepted_limit_order(
&ctx,
included_id,
VenueOrderId::from("V-QUERY-009"),
test_client_id(),
);
insert_accepted_limit_order_for_instrument(
&ctx,
excluded_id,
VenueOrderId::from("V-QUERY-010"),
test_instrument_id2(),
ClientId::from("BITMEX"),
);
let queries = ctx.manager.check_open_order_queries();
assert_eq!(queries.len(), 1);
match &queries[0] {
TradingCommand::QueryOrder(query) => {
assert_eq!(query.client_order_id, included_id);
assert_eq!(query.instrument_id, test_instrument_id());
}
command => panic!("Expected QueryOrder, was {command:?}"),
}
}
fn insert_accepted_limit_order(
ctx: &TestContext,
client_order_id: ClientOrderId,
venue_order_id: VenueOrderId,
client_id: ClientId,
) {
insert_accepted_limit_order_for_instrument(
ctx,
client_order_id,
venue_order_id,
test_instrument_id(),
client_id,
);
}
fn insert_accepted_limit_order_for_instrument(
ctx: &TestContext,
client_order_id: ClientOrderId,
venue_order_id: VenueOrderId,
instrument_id: InstrumentId,
client_id: ClientId,
) {
let order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order_with_client_id(order, client_id);
let order = ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
ctx.cache.borrow_mut().update_order(&accepted).unwrap();
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_defers_with_recent_local_activity() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 200_000_000, ..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order.clone());
ctx.manager.record_local_activity(client_order_id);
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"),
)
.with_avg_px(dec!(100.0));
let mock_client = MockExecutionClient::new(vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(
events.is_empty(),
"Reconciliation should be deferred with recent local activity"
);
let cached_order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(cached_order.status(), OrderStatus::Accepted);
assert_eq!(cached_order.filled_qty(), Quantity::from("0.0"));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_proceeds_after_threshold_exceeded() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 200_000_000, ..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order.clone());
ctx.manager.record_local_activity(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(500))
.await;
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"),
)
.with_avg_px(dec!(100.0));
let mock_client = MockExecutionClient::new(vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(
events.len(),
1,
"Reconciliation should proceed when threshold exceeded"
);
if let OrderEventAny::Filled(filled) = &events[0] {
assert_eq!(filled.last_qty, Quantity::from("5.0"));
} else {
panic!("Expected OrderFilled event, was {:?}", events[0]);
}
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_proceeds_without_local_activity() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 200_000_000, ..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-001");
let venue_order_id = VenueOrderId::from("V-001");
let instrument_id = test_instrument_id();
let mut order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
order.apply(submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
order.apply(accepted).unwrap();
ctx.add_order(order.clone());
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
instrument_id,
OrderStatus::PartiallyFilled,
Quantity::from("10.0"),
Quantity::from("5.0"),
)
.with_avg_px(dec!(100.0));
let mock_client = MockExecutionClient::new(vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(
events.len(),
1,
"Reconciliation should proceed without local activity"
);
if let OrderEventAny::Filled(filled) = &events[0] {
assert_eq!(filled.last_qty, Quantity::from("5.0"));
} else {
panic!("Expected OrderFilled event, was {:?}", events[0]);
}
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_submitted_missing_at_venue_generates_rejected() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let order = create_limit_order(
"O-001",
test_instrument_id(),
OrderSide::Buy,
"10.0",
"100.0",
);
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order(order);
ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(events.len(), 1);
if let OrderEventAny::Rejected(rejected) = &events[0] {
assert_eq!(rejected.client_order_id, ClientOrderId::from("O-001"));
assert_eq!(rejected.reason.as_str(), "NOT_FOUND_AT_VENUE");
} else {
panic!("Expected OrderRejected event, was {:?}", events[0]);
}
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_targeted_query_prevents_false_rejection() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
single_order_query_delay_ms: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-TARGETED-FOUND");
let venue_order_id = VenueOrderId::from("V-TARGETED-FOUND");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::zero(1),
)
.with_price(Price::from("100.0"));
let mock_client = MockExecutionClient::new(Vec::new()).with_order_report(report);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(mock_client.order_report_query_count.get(), 1);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 0);
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_targeted_query_error_defers_resolution() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
single_order_query_delay_ms: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-TARGETED-ERROR");
let venue_order_id = VenueOrderId::from("V-TARGETED-ERROR");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let mock_client = MockExecutionClient::new(Vec::new()).with_failed_order_report();
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(mock_client.order_report_query_count.get(), 1);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 1);
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_mismatched_targeted_report_defers_resolution() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
single_order_query_delay_ms: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-TARGETED-MISMATCH");
let venue_order_id = VenueOrderId::from("V-TARGETED-MISMATCH");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let report = create_order_status_report(
Some(ClientOrderId::from("O-OTHER")),
VenueOrderId::from("V-OTHER"),
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::zero(1),
)
.with_price(Price::from("100.0"));
let mock_client = MockExecutionClient::new(Vec::new()).with_order_report(report);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(mock_client.order_report_query_count.get(), 1);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 1);
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_caps_targeted_queries_per_cycle() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
max_single_order_queries_per_cycle: 1,
single_order_query_delay_ms: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_a = ClientId::from("CLIENT-A");
let client_b = ClientId::from("CLIENT-B");
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-TARGETED-A"),
VenueOrderId::from("V-TARGETED-A"),
client_a,
);
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-TARGETED-B"),
VenueOrderId::from("V-TARGETED-B"),
client_b,
);
let mock_a = MockExecutionClient::for_venue(client_a, test_venue(), Vec::new());
let mock_b = MockExecutionClient::for_venue(client_b, test_venue(), Vec::new());
let clients: Vec<&dyn ExecutionClient> = vec![&mock_a, &mock_b];
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(events.len(), 1);
assert_eq!(mock_a.order_report_query_count.get(), 1);
assert_eq!(mock_b.order_report_query_count.get(), 0);
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_queries_oversized_responsible_client_group() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
max_single_order_queries_per_cycle: 1,
single_order_query_delay_ms: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-TARGETED-MULTI");
let venue_order_id = VenueOrderId::from("V-TARGETED-MULTI");
let order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(test_instrument_id())
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order(order);
let order = ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(&order, test_account_id(), venue_order_id);
ctx.cache.borrow_mut().update_order(&accepted).unwrap();
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::zero(1),
)
.with_price(Price::from("100.0"));
let mock_a =
MockExecutionClient::for_venue(ClientId::from("CLIENT-A"), test_venue(), Vec::new());
let mock_b =
MockExecutionClient::for_venue(ClientId::from("CLIENT-B"), test_venue(), Vec::new())
.with_order_report(report);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_a, &mock_b];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(
(
mock_a.order_report_query_count.get(),
mock_b.order_report_query_count.get(),
ctx.manager.recon_check_retry_count(&client_order_id),
),
(1, 1, 0),
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_spaces_targeted_queries() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
max_single_order_queries_per_cycle: 2,
single_order_query_delay_ms: 100,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_a = ClientId::from("CLIENT-A");
let client_b = ClientId::from("CLIENT-B");
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-TARGETED-A"),
VenueOrderId::from("V-TARGETED-A"),
client_a,
);
insert_accepted_limit_order(
&ctx,
ClientOrderId::from("O-TARGETED-B"),
VenueOrderId::from("V-TARGETED-B"),
client_b,
);
let query_times = Rc::new(RefCell::new(Vec::new()));
let mock_a = MockExecutionClient::for_venue(client_a, test_venue(), Vec::new())
.with_on_order_report_query(Box::new({
let query_times = query_times.clone();
move || query_times.borrow_mut().push(dst::time::Instant::now())
}));
let mock_b = MockExecutionClient::for_venue(client_b, test_venue(), Vec::new())
.with_on_order_report_query(Box::new({
let query_times = query_times.clone();
move || query_times.borrow_mut().push(dst::time::Instant::now())
}));
let clients: Vec<&dyn ExecutionClient> = vec![&mock_a, &mock_b];
let events = ctx.manager.check_open_orders(&clients).await;
let query_times = query_times.borrow();
assert_eq!(events.len(), 2);
assert_eq!(query_times.len(), 2);
assert!(query_times[1].duration_since(query_times[0]) >= dst::time::Duration::from_millis(100));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_partially_filled_missing_at_venue_generates_canceled() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
ctx.add_instrument(instrument.clone());
let client_order_id = ClientOrderId::from("O-PARTIAL-MISSING");
let venue_order_id = VenueOrderId::from("V-PARTIAL-MISSING");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let order = ctx.get_order(&client_order_id).unwrap();
let fill = OrderFilledTestBuilder::new(&order, &instrument)
.last_qty(Quantity::from("4.0"))
.without_position_id()
.build();
ctx.cache.borrow_mut().update_order(&fill).unwrap();
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], OrderEventAny::Canceled(canceled) if canceled.client_order_id == client_order_id),
"expected OrderCanceled event, was {:?}",
events[0],
);
ctx.cache.borrow_mut().update_order(&events[0]).unwrap();
let order = ctx.get_order(&client_order_id).unwrap();
assert_eq!(order.status(), OrderStatus::Canceled);
assert_eq!(order.filled_qty(), Quantity::from("4.0"));
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_failed_client_does_not_advance_missing_retries() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 2,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
ctx.add_instrument(test_instrument2());
let healthy_order_id = ClientOrderId::from("O-HEALTHY-MISSING");
let failed_order_id = ClientOrderId::from("O-FAILED-VENUE");
let healthy_client_id = test_client_id();
let failed_client_id = ClientId::from("BITMEX");
let failed_venue = Venue::from("BITMEX");
insert_accepted_limit_order(
&ctx,
healthy_order_id,
VenueOrderId::from("V-HEALTHY-MISSING"),
healthy_client_id,
);
insert_accepted_limit_order_for_instrument(
&ctx,
failed_order_id,
VenueOrderId::from("V-FAILED-VENUE"),
test_instrument_id2(),
failed_client_id,
);
let healthy_client = MockExecutionClient::for_venue(healthy_client_id, test_venue(), vec![]);
let failed_client = MockExecutionClient::failing(failed_client_id, failed_venue);
let clients: Vec<&dyn ExecutionClient> = vec![&healthy_client, &failed_client];
let first_events = ctx.manager.check_open_orders(&clients).await;
assert!(first_events.is_empty());
advance_clock(dst::time::Duration::from_millis(1)).await;
let recovered_client = MockExecutionClient::for_venue(failed_client_id, failed_venue, vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&healthy_client, &recovered_client];
let second_events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(second_events.len(), 1);
assert!(
matches!(&second_events[0], OrderEventAny::Rejected(rejected) if rejected.client_order_id == healthy_order_id),
"only the healthy venue order should exhaust its retry budget",
);
assert_eq!(
ctx.get_order(&failed_order_id).unwrap().status(),
OrderStatus::Accepted,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_failed_routing_client_does_not_resolve_exchange_order() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let client_order_id = ClientOrderId::from("O-FAILED-ROUTING");
let routing_client_id = ClientId::from("IB");
ctx.add_instrument(instrument);
insert_accepted_limit_order(
&ctx,
client_order_id,
VenueOrderId::from("V-FAILED-ROUTING"),
routing_client_id,
);
let routing_client = MockExecutionClient::failing(routing_client_id, Venue::from("IB"))
.with_account_id(AccountId::from("IB-001"))
.with_handled_venues(IndexSet::from([instrument_id.venue]));
let clients: Vec<&dyn ExecutionClient> = vec![&routing_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.get_order(&client_order_id).unwrap().status(),
OrderStatus::Accepted,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_failed_client_does_not_suppress_healthy_client_same_venue() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let failed_order_id = ClientOrderId::from("O-FAILED-SHARED-VENUE");
let healthy_order_id = ClientOrderId::from("O-HEALTHY-SHARED-VENUE");
let failed_client_id = ClientId::from("SHARED-FAILED");
let healthy_client_id = ClientId::from("SHARED-HEALTHY");
insert_accepted_limit_order(
&ctx,
failed_order_id,
VenueOrderId::from("V-FAILED-SHARED-VENUE"),
failed_client_id,
);
insert_accepted_limit_order(
&ctx,
healthy_order_id,
VenueOrderId::from("V-HEALTHY-SHARED-VENUE"),
healthy_client_id,
);
let failed_client = MockExecutionClient::failing(failed_client_id, test_venue());
let healthy_client = MockExecutionClient::for_venue(healthy_client_id, test_venue(), vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&failed_client, &healthy_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], OrderEventAny::Rejected(rejected) if rejected.client_order_id == healthy_order_id),
"only the healthy-client order should be rejected",
);
assert_eq!(
ctx.get_order(&failed_order_id).unwrap().status(),
OrderStatus::Accepted,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_failed_routing_client_fallback_coverage_does_not_advance() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let client_order_id = ClientOrderId::from("O-FAILED-ROUTING-FALLBACK");
ctx.add_instrument(instrument);
let order = OrderTestBuilder::new(OrderType::Limit)
.client_order_id(client_order_id)
.instrument_id(instrument_id)
.quantity(Quantity::from("10.0"))
.price(Price::from("100.0"))
.build();
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order(order);
let order = ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let accepted = TestOrderEventStubs::accepted(
&order,
test_account_id(),
VenueOrderId::from("V-FAILED-ROUTING-FALLBACK"),
);
ctx.cache.borrow_mut().update_order(&accepted).unwrap();
let routing_client = MockExecutionClient::failing(ClientId::from("IB"), Venue::from("IB"))
.with_account_id(AccountId::from("IB-001"))
.with_handled_venues(IndexSet::from([instrument_id.venue]));
let clients: Vec<&dyn ExecutionClient> = vec![&routing_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.get_order(&client_order_id).unwrap().status(),
OrderStatus::Accepted,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_positive_report_resets_missing_retry_ladder() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 2,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-RETRY-RESET");
let venue_order_id = VenueOrderId::from("V-RETRY-RESET");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let empty_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&empty_client];
let first_events = ctx.manager.check_open_orders(&clients).await;
assert!(first_events.is_empty());
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 1);
advance_clock(dst::time::Duration::from_millis(1)).await;
let report = create_order_status_report(
Some(client_order_id),
venue_order_id,
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::from("0.0"),
);
let reporting_client = MockExecutionClient::new(vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&reporting_client];
let second_events = ctx.manager.check_open_orders(&clients).await;
assert!(
!second_events
.iter()
.any(|e| matches!(e, OrderEventAny::Rejected(_) | OrderEventAny::Canceled(_))),
"a positive matching report must not resolve the order",
);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 0);
advance_clock(dst::time::Duration::from_millis(1)).await;
let empty_again = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&empty_again];
let third_events = ctx.manager.check_open_orders(&clients).await;
assert!(
third_events.is_empty(),
"non-consecutive misses must not exhaust the retry budget",
);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 1);
assert_eq!(
ctx.get_order(&client_order_id).unwrap().status(),
OrderStatus::Accepted,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_venue_id_only_report_resets_missing_retry_ladder() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 2,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-VENUE-ID-ONLY");
let venue_order_id = VenueOrderId::from("V-VENUE-ID-ONLY");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let empty_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&empty_client];
let first_events = ctx.manager.check_open_orders(&clients).await;
assert!(first_events.is_empty());
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 1);
advance_clock(dst::time::Duration::from_millis(1)).await;
let report = create_order_status_report(
None,
venue_order_id,
test_instrument_id(),
OrderStatus::Accepted,
Quantity::from("10.0"),
Quantity::from("0.0"),
);
let reporting_client = MockExecutionClient::new(vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&reporting_client];
let second_events = ctx.manager.check_open_orders(&clients).await;
assert!(
!second_events
.iter()
.any(|e| matches!(e, OrderEventAny::Rejected(_) | OrderEventAny::Canceled(_))),
"a venue-ID-only positive report must not resolve the order",
);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 0);
advance_clock(dst::time::Duration::from_millis(1)).await;
let empty_again = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&empty_again];
let third_events = ctx.manager.check_open_orders(&clients).await;
assert!(
third_events.is_empty(),
"a single miss after a venue-ID-only positive report must not reject",
);
assert_eq!(ctx.manager.recon_check_retry_count(&client_order_id), 1);
assert_eq!(
ctx.get_order(&client_order_id).unwrap().status(),
OrderStatus::Accepted,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_order_closed_during_query_leaves_no_retry_state() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 2,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-CLOSED-DURING-QUERY");
let venue_order_id = VenueOrderId::from("V-CLOSED-DURING-QUERY");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let cache = ctx.cache.clone();
let client =
MockExecutionClient::new(vec![]).with_on_order_reports_query(Box::new(move || {
let order = cache.borrow().order(&client_order_id).unwrap().clone();
let canceled =
TestOrderEventStubs::canceled(&order, test_account_id(), Some(venue_order_id));
cache.borrow_mut().update_order(&canceled).unwrap();
}));
let clients: Vec<&dyn ExecutionClient> = vec![&client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager.recon_check_retry_count(&client_order_id),
0,
"an order that closed during the query must leave no retry state behind",
);
assert_eq!(
ctx.get_order(&client_order_id).unwrap().status(),
OrderStatus::Canceled,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_deferred_pending_order_keeps_inflight_registration() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: false,
inflight_threshold_ms: 100,
inflight_max_retries: 2,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let client_order_id = ClientOrderId::from("O-PENDING-DEFER");
let venue_order_id = VenueOrderId::from("V-PENDING-DEFER");
insert_accepted_limit_order(&ctx, client_order_id, venue_order_id, test_client_id());
let order = ctx.get_order(&client_order_id).unwrap();
let pending = OrderEventAny::PendingCancel(
OrderPendingCancelSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(client_order_id)
.account_id(test_account_id())
.venue_order_id(venue_order_id)
.build(),
);
ctx.cache.borrow_mut().update_order(&pending).unwrap();
assert_eq!(
ctx.get_order(&client_order_id).unwrap().status(),
OrderStatus::PendingCancel,
);
ctx.manager.register_inflight(client_order_id);
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let primed = ctx.manager.check_inflight_orders();
assert!(primed.events.is_empty());
assert_eq!(primed.queries.len(), 1);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
ctx.advance_both(dst::time::Duration::from_millis(200))
.await;
let result = ctx.manager.check_inflight_orders();
assert!(
result.events.is_empty(),
"deferral must reset the inflight retry ladder, was {:?}",
result.events,
);
assert_eq!(
result.queries.len(),
1,
"deferred pending order should remain registered for inflight recovery",
);
}
#[rstest]
#[tokio::test]
async fn test_check_open_orders_open_only_missing_venue_order_does_not_reject() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 0,
open_check_missing_retries: 1,
open_check_open_only: true,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let order = create_limit_order(
"O-OPEN-ONLY",
test_instrument_id(),
OrderSide::Buy,
"10.0",
"100.0",
);
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order(order);
ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(events.is_empty());
let cached_order = ctx.get_order(&ClientOrderId::from("O-OPEN-ONLY")).unwrap();
assert_eq!(cached_order.status(), OrderStatus::Submitted);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_missing_gate_uses_local_activity_not_venue_ts_last() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 200_000_000,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let order = create_limit_order(
"O-AHEAD",
test_instrument_id(),
OrderSide::Buy,
"10.0",
"100.0",
);
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order(order);
let order = ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let future_ts = ctx
.clock
.borrow()
.timestamp_ns()
.saturating_add_ns(10_000_000_000_u64);
let accepted = OrderEventAny::Accepted(
OrderAcceptedSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.venue_order_id(VenueOrderId::from("V-AHEAD"))
.account_id(test_account_id())
.ts_event(future_ts)
.ts_init(future_ts)
.build(),
);
let client_order_id = order.client_order_id();
ctx.cache.borrow_mut().update_order(&accepted).unwrap();
ctx.manager.observe_order_event(&accepted);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(
events.is_empty(),
"recent local activity should defer reconciliation",
);
advance_clock(dst::time::Duration::from_millis(250)).await;
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], OrderEventAny::Rejected(rejected) if rejected.client_order_id == client_order_id),
"far-future venue ts_last must not keep deferring reconciliation",
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_check_open_orders_defers_for_just_accepted_order() {
let config = ExecutionManagerConfig {
open_check_threshold_ns: 200_000_000,
open_check_missing_retries: 1,
open_check_open_only: false,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
ctx.add_instrument(test_instrument());
let order = create_limit_order(
"O-ACCEPTED",
test_instrument_id(),
OrderSide::Buy,
"10.0",
"100.0",
);
let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
ctx.add_order(order);
let order = ctx.cache.borrow_mut().update_order(&submitted).unwrap();
let client_order_id = order.client_order_id();
let accepted =
TestOrderEventStubs::accepted(&order, test_account_id(), VenueOrderId::from("V-ACCEPTED"));
ctx.cache.borrow_mut().update_order(&accepted).unwrap();
ctx.manager.observe_order_event(&accepted);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_open_orders(&clients).await;
assert!(
events.is_empty(),
"a just-accepted order must defer missing-order reconciliation",
);
advance_clock(dst::time::Duration::from_millis(250)).await;
let events = ctx.manager.check_open_orders(&clients).await;
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], OrderEventAny::Rejected(rejected) if rejected.client_order_id == client_order_id),
"reconciliation must proceed once the local-activity grace expires",
);
}
#[tokio::test]
async fn test_position_check_reconciles_venue_only_nonflat_report() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
ctx.add_instrument(instrument);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
events
.iter()
.any(|event| matches!(event, OrderEventAny::Filled(_))),
"Expected a synthetic fill for a non-flat venue-only position report"
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_rereads_position_closed_during_request() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let position = create_test_position(
&instrument,
PositionId::from("P-CLOSE-DURING-REQUEST"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let closed_position = close_test_long_position(
position.clone(),
&instrument,
"5.0",
TradeId::from("T-CLOSE-DURING-REQUEST"),
);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![])
.with_position_request_mutation(PositionRequestMutation::Update {
cache: ctx.cache.clone(),
position: closed_position,
});
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
for event in &events {
ctx.exec_engine.borrow_mut().process(event);
}
let open_positions = ctx
.cache
.borrow()
.positions_open(None, Some(&test_instrument_id()), None, None, None)
.len();
assert!(events.is_empty());
assert_eq!(
open_positions, 0,
"a synthetic sell from the stale long would open a real short",
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_rereads_position_opened_during_request() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position = create_test_position(
&instrument,
PositionId::from("P-OPEN-DURING-REQUEST"),
OrderSide::Buy,
"3.0",
"3000.00",
);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
ctx.add_instrument(instrument);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report])
.with_position_request_mutation(PositionRequestMutation::Add {
cache: ctx.cache.clone(),
position,
});
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
events.is_empty(),
"the matching report must not recreate a position opened during the request",
);
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_uses_current_avg_px_after_request() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position_id = PositionId::from("P-AVG-PX-DURING-REQUEST");
let stale_position =
create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
let current_position =
create_test_position(&instrument, position_id, OrderSide::Buy, "3.0", "3100.00");
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("6.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3200.00)),
);
ctx.add_instrument(instrument);
ctx.add_position(&stale_position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report])
.with_position_request_mutation(PositionRequestMutation::Update {
cache: ctx.cache.clone(),
position: current_position,
});
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
let fill = events
.iter()
.find_map(|event| match event {
OrderEventAny::Filled(fill) => Some(fill),
_ => None,
})
.expect("expected a reconciliation fill");
assert_eq!(fill.last_qty, Quantity::from("3.0"));
assert_eq!(fill.last_px, Price::from("3300.00"));
}
#[rstest]
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_preserves_retry_for_uncovered_position_opened_during_request() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let key = (instrument_id, test_account_id());
let old_position = create_test_position(
&instrument,
PositionId::from("P-RETRY-OLD"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&old_position);
let initial_client = MockPositionExecutionClient::new(vec![], vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&initial_client];
assert!(
ctx.manager
.check_positions_consistency(&clients)
.await
.is_empty()
);
assert_eq!(ctx.manager.position_recon_retry_count(&key), 1);
let closed_position = close_test_long_position(
old_position,
&instrument,
"5.0",
TradeId::from("T-RETRY-CLOSE"),
);
ctx.cache
.borrow_mut()
.update_position(&closed_position)
.unwrap();
ctx.add_instrument(instrument.clone());
let new_position = create_test_position(
&instrument,
PositionId::from("P-RETRY-NEW"),
OrderSide::Buy,
"3.0",
"3100.00",
);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("4.0"),
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
None,
Some(dec!(3200.00)),
);
let request_client = MockPositionExecutionClient::new(vec![], vec![venue_report])
.with_position_request_mutation(PositionRequestMutation::Add {
cache: ctx.cache.clone(),
position: new_position,
});
let clients: Vec<&dyn ExecutionClient> = vec![&request_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager.position_recon_retry_count(&key),
1,
"the uncovered key must be deferred and remain active for retry pruning",
);
}
#[tokio::test]
async fn test_position_check_retries_stops_after_max() {
let config = ExecutionManagerConfig {
position_check_retries: 2,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let position_id = PositionId::from("P-001");
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_position(&position);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
}
#[tokio::test]
async fn test_position_check_retries_clears_when_discrepancy_resolves() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let position_id = PositionId::from("P-001");
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_position(&position);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
ctx.add_instrument(instrument.clone());
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
!events.is_empty(),
"Expected reconciliation events when instrument is available"
);
}
#[tokio::test]
async fn test_position_check_stale_retries_pruned_when_position_closed() {
let config = ExecutionManagerConfig {
position_check_retries: 1,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position_id = PositionId::from("P-001");
ctx.add_instrument(instrument.clone());
let position = create_test_position(&instrument, position_id, OrderSide::Buy, "5.0", "3000.00");
ctx.add_position(&position);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(!events.is_empty());
ctx.manager.check_positions_consistency(&clients).await;
let close_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Sell)
.quantity(Quantity::from("5.0"))
.build();
let close_fill = TestOrderEventStubs::filled(
&close_order,
&instrument,
Some(TradeId::new("T-CLOSE-001")),
Some(position_id),
Some(Price::from("3000.00")),
Some(Quantity::from("5.0")),
None,
None,
None,
Some(test_account_id()),
);
let close_filled: OrderFilled = close_fill.into();
let mut pos = position;
pos.apply(&close_filled);
ctx.cache.borrow_mut().update_position(&pos).unwrap();
ctx.manager.check_positions_consistency(&clients).await;
let position2 = create_test_position(
&instrument,
PositionId::from("P-002"),
OrderSide::Buy,
"3.0",
"3100.00",
);
ctx.add_position(&position2);
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
!events.is_empty(),
"Expected events: stale retry counter should have been pruned"
);
}
enum PositionRequestMutation {
Add {
cache: Rc<RefCell<Cache>>,
position: Position,
},
Update {
cache: Rc<RefCell<Cache>>,
position: Position,
},
}
struct MockPositionExecutionClient {
client_id: ClientId,
account_id: AccountId,
venue: Venue,
handled_venues: Option<IndexSet<Venue>>,
order_reports: RefCell<Vec<OrderStatusReport>>,
position_reports: RefCell<Vec<PositionStatusReport>>,
position_request_mutation: RefCell<Option<PositionRequestMutation>>,
fail_position_reports: bool,
position_reconciliation_tolerance: Decimal,
}
impl MockPositionExecutionClient {
fn new(
order_reports: Vec<OrderStatusReport>,
position_reports: Vec<PositionStatusReport>,
) -> Self {
Self {
client_id: test_client_id(),
account_id: test_account_id(),
venue: test_venue(),
handled_venues: None,
order_reports: RefCell::new(order_reports),
position_reports: RefCell::new(position_reports),
position_request_mutation: RefCell::new(None),
fail_position_reports: false,
position_reconciliation_tolerance: dec!(0.00000001),
}
}
fn with_position_reconciliation_tolerance(mut self, tolerance: Decimal) -> Self {
self.position_reconciliation_tolerance = tolerance;
self
}
fn with_position_reports(mut self, position_reports: Vec<PositionStatusReport>) -> Self {
self.position_reports = RefCell::new(position_reports);
self
}
fn with_position_request_mutation(mut self, mutation: PositionRequestMutation) -> Self {
self.position_request_mutation = RefCell::new(Some(mutation));
self
}
fn failing_position_reports() -> Self {
Self {
client_id: test_client_id(),
account_id: test_account_id(),
venue: test_venue(),
handled_venues: None,
order_reports: RefCell::new(Vec::new()),
position_reports: RefCell::new(Vec::new()),
position_request_mutation: RefCell::new(None),
fail_position_reports: true,
position_reconciliation_tolerance: dec!(0.00000001),
}
}
fn configured(
client_id: ClientId,
account_id: AccountId,
venue: Venue,
handled_venues: IndexSet<Venue>,
fail_position_reports: bool,
) -> Self {
Self {
client_id,
account_id,
venue,
handled_venues: Some(handled_venues),
order_reports: RefCell::new(Vec::new()),
position_reports: RefCell::new(Vec::new()),
position_request_mutation: RefCell::new(None),
fail_position_reports,
position_reconciliation_tolerance: dec!(0.00000001),
}
}
}
#[async_trait(?Send)]
impl ExecutionClient for MockPositionExecutionClient {
fn is_connected(&self) -> bool {
true
}
fn client_id(&self) -> ClientId {
self.client_id
}
fn account_id(&self) -> AccountId {
self.account_id
}
fn venue(&self) -> Venue {
self.venue
}
fn handles_order_venue(&self, venue: Venue) -> bool {
self.handled_venues
.as_ref()
.map_or(self.venue == venue, |handled| handled.contains(&venue))
}
fn oms_type(&self) -> OmsType {
OmsType::Hedging
}
fn get_account(&self) -> Option<AccountAny> {
None
}
fn position_reconciliation_tolerance(&self) -> Decimal {
self.position_reconciliation_tolerance
}
fn generate_account_state(
&self,
_balances: Vec<AccountBalance>,
_margins: Vec<MarginBalance>,
_reported: bool,
_ts_event: UnixNanos,
) -> anyhow::Result<()> {
Ok(())
}
fn start(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn stop(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn submit_order(&self, _cmd: SubmitOrder) -> anyhow::Result<()> {
Ok(())
}
fn submit_order_list(&self, _cmd: SubmitOrderList) -> anyhow::Result<()> {
Ok(())
}
fn modify_order(&self, _cmd: ModifyOrder) -> anyhow::Result<()> {
Ok(())
}
fn cancel_order(&self, _cmd: CancelOrder) -> anyhow::Result<()> {
Ok(())
}
fn cancel_all_orders(&self, _cmd: CancelAllOrders) -> anyhow::Result<()> {
Ok(())
}
fn batch_cancel_orders(&self, _cmd: BatchCancelOrders) -> anyhow::Result<()> {
Ok(())
}
fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
Ok(())
}
fn query_order(&self, _cmd: QueryOrder) -> anyhow::Result<()> {
Ok(())
}
async fn generate_order_status_reports(
&self,
_cmd: &GenerateOrderStatusReports,
) -> anyhow::Result<Vec<OrderStatusReport>> {
Ok(self.order_reports.borrow().clone())
}
async fn generate_position_status_reports(
&self,
_cmd: &GeneratePositionStatusReports,
) -> anyhow::Result<Vec<PositionStatusReport>> {
let mutation = { self.position_request_mutation.borrow_mut().take() };
if let Some(mutation) = mutation {
dst::time::sleep(dst::time::Duration::from_secs(1)).await;
match mutation {
PositionRequestMutation::Add { cache, position } => cache
.borrow_mut()
.add_position(&position, OmsType::Hedging)
.unwrap(),
PositionRequestMutation::Update { cache, position } => {
cache.borrow_mut().update_position(&position).unwrap();
}
}
}
if self.fail_position_reports {
anyhow::bail!("position reports unavailable");
}
Ok(self.position_reports.borrow().clone())
}
}
#[tokio::test]
async fn test_position_check_uses_client_tolerance_for_missing_dust_report() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let position = create_test_position(
&instrument,
PositionId::from("P-DUST-MISSING"),
OrderSide::Buy,
"0.008007",
"3000.00",
);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![])
.with_position_reconciliation_tolerance(dec!(0.009999));
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
}
#[tokio::test]
async fn test_position_check_uses_client_tolerance_for_observed_smoke_difference() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position = create_test_position(
&instrument,
PositionId::from("P-SMOKE-DIFFERENCE"),
OrderSide::Buy,
"5.202897",
"3000.00",
);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.208000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report])
.with_position_reconciliation_tolerance(dec!(0.009999));
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_uses_routing_client_tolerance() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_id = AccountId::from("IB-ROUTING-CHECK");
let position = create_test_position_for_account(
&instrument,
PositionId::from("P-ROUTING-TOLERANCE-CHECK"),
OrderSide::Buy,
"5.000000",
"3000.00",
account_id,
);
let venue_report = PositionStatusReport::new(
account_id,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.005000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
ctx.add_instrument(instrument);
ctx.add_margin_account(account_id);
ctx.add_position(&position);
let routing_client = MockPositionExecutionClient::configured(
ClientId::from("IB-ROUTING-CHECK"),
account_id,
Venue::from("IB"),
IndexSet::from([instrument_id.venue]),
false,
)
.with_position_reports(vec![venue_report])
.with_position_reconciliation_tolerance(dec!(0.010000));
let clients: Vec<&dyn ExecutionClient> = vec![&routing_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_mass_status_netting_uses_routing_client_tolerance() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_id = AccountId::from("IB-ROUTING-MASS-STATUS");
let position = create_test_position_for_account(
&instrument,
PositionId::from("P-ROUTING-TOLERANCE-MASS-STATUS"),
OrderSide::Buy,
"5.000000",
"3000.00",
account_id,
);
let matching_report = PositionStatusReport::new(
account_id,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.000000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
ctx.add_instrument(instrument);
ctx.add_margin_account(account_id);
ctx.add_position(&position);
let routing_client = MockPositionExecutionClient::configured(
ClientId::from("IB-ROUTING-MASS-STATUS"),
account_id,
Venue::from("IB"),
IndexSet::from([instrument_id.venue]),
false,
)
.with_position_reports(vec![matching_report])
.with_position_reconciliation_tolerance(dec!(0.010000));
let clients: Vec<&dyn ExecutionClient> = vec![&routing_client];
let seed_events = ctx.manager.check_positions_consistency(&clients).await;
assert!(seed_events.is_empty());
let mut mass_status = ExecutionMassStatus::new(
routing_client.client_id(),
account_id,
routing_client.venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let drift_report = PositionStatusReport::new(
account_id,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.005000"),
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
None,
Some(dec!(3000.00)),
);
mass_status.add_position_reports(vec![drift_report]);
let result = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
assert!(result.events.is_empty());
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_routing_clients_on_same_venue_use_account_tolerances_independently() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let tolerant_account_id = AccountId::from("IB-TOLERANT");
let strict_account_id = AccountId::from("IB-STRICT");
let tolerant_position = create_test_position_for_account(
&instrument,
PositionId::from("P-ROUTING-TOLERANT"),
OrderSide::Buy,
"5.000000",
"3000.00",
tolerant_account_id,
);
let strict_position = create_test_position_for_account(
&instrument,
PositionId::from("P-ROUTING-STRICT"),
OrderSide::Buy,
"5.000000",
"3000.00",
strict_account_id,
);
let tolerant_report = PositionStatusReport::new(
tolerant_account_id,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.005000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
let strict_report = PositionStatusReport::new(
strict_account_id,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.005000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
ctx.add_instrument(instrument);
ctx.add_margin_account(tolerant_account_id);
ctx.add_margin_account(strict_account_id);
ctx.add_position(&tolerant_position);
ctx.add_position(&strict_position);
let tolerant_client = MockPositionExecutionClient::configured(
ClientId::from("IB-TOLERANT"),
tolerant_account_id,
Venue::from("IB"),
IndexSet::from([instrument_id.venue]),
false,
)
.with_position_reports(vec![tolerant_report])
.with_position_reconciliation_tolerance(dec!(0.010000));
let strict_client = MockPositionExecutionClient::configured(
ClientId::from("IB-STRICT"),
strict_account_id,
Venue::from("IB"),
IndexSet::from([instrument_id.venue]),
false,
)
.with_position_reports(vec![strict_report])
.with_position_reconciliation_tolerance(dec!(0.001000));
let clients: Vec<&dyn ExecutionClient> = vec![&tolerant_client, &strict_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
let fill_account_ids = events
.iter()
.filter_map(|event| match event {
OrderEventAny::Filled(fill) => Some(fill.account_id),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(fill_account_ids, vec![strict_account_id]);
}
#[tokio::test]
async fn test_position_check_reconciles_at_client_tolerance_boundary() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position = create_test_position(
&instrument,
PositionId::from("P-TOLERANCE-BOUNDARY"),
OrderSide::Buy,
"5.200000",
"3000.00",
);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.210000"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report])
.with_position_reconciliation_tolerance(dec!(0.009999));
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events
.iter()
.any(|event| matches!(event, OrderEventAny::Filled(fill) if fill.last_qty == Quantity::from("0.010000"))));
}
#[tokio::test]
async fn test_position_check_failed_client_query_skips_cached_position() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position = create_test_position(
&instrument,
PositionId::from("P-FAIL-SKIP"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&position);
let ok_client = MockPositionExecutionClient::new(vec![], vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&ok_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
1
);
ctx.add_instrument(instrument);
let failing_client = MockPositionExecutionClient::failing_position_reports();
let clients: Vec<&dyn ExecutionClient> = vec![&failing_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
!events
.iter()
.any(|event| matches!(event, OrderEventAny::Filled(_))),
"Expected no synthetic fill when the venue position query fails"
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
1
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_failed_routing_client_leaves_exchange_retry_untouched() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_id = AccountId::from("IB-001");
let position = create_test_position_for_account(
&instrument,
PositionId::from("P-FAILED-ROUTING"),
OrderSide::Buy,
"5.0",
"3000.00",
account_id,
);
ctx.add_margin_account(account_id);
ctx.add_position(&position);
let routing_client = MockPositionExecutionClient::configured(
ClientId::from("IB"),
account_id,
Venue::from("IB"),
IndexSet::from([instrument_id.venue]),
true,
);
let clients: Vec<&dyn ExecutionClient> = vec![&routing_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_id)),
0,
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_failed_client_does_not_suppress_healthy_account_same_venue() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let failed_account_id = AccountId::from("BINANCE-FAILED");
let healthy_account_id = AccountId::from("BINANCE-HEALTHY");
let failed_position = create_test_position_for_account(
&instrument,
PositionId::from("P-FAILED-SHARED-VENUE"),
OrderSide::Buy,
"5.0",
"3000.00",
failed_account_id,
);
let healthy_position = create_test_position_for_account(
&instrument,
PositionId::from("P-HEALTHY-SHARED-VENUE"),
OrderSide::Buy,
"7.0",
"3000.00",
healthy_account_id,
);
ctx.add_margin_account(failed_account_id);
ctx.add_margin_account(healthy_account_id);
ctx.add_position(&failed_position);
ctx.add_position(&healthy_position);
let failed_client = MockPositionExecutionClient::configured(
ClientId::from("SHARED-FAILED"),
failed_account_id,
test_venue(),
IndexSet::from([instrument_id.venue]),
true,
);
let healthy_client = MockPositionExecutionClient::configured(
ClientId::from("SHARED-HEALTHY"),
healthy_account_id,
test_venue(),
IndexSet::from([instrument_id.venue]),
false,
);
let clients: Vec<&dyn ExecutionClient> = vec![&failed_client, &healthy_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, failed_account_id)),
0,
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, healthy_account_id)),
1,
);
}
#[tokio::test]
async fn test_position_check_aggregates_hedge_positions_before_comparing_report() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
ctx.add_instrument(instrument.clone());
let pos_long = create_test_position(
&instrument,
PositionId::from("P-NET-LONG"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let pos_short = create_test_position(
&instrument,
PositionId::from("P-NET-SHORT"),
OrderSide::Sell,
"3.0",
"3100.00",
);
ctx.add_position(&pos_long);
ctx.add_position(&pos_short);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("2.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3050.00)),
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
!events
.iter()
.any(|event| matches!(event, OrderEventAny::Filled(_))),
"Expected no synthetic fill when cached hedge positions net to the venue report"
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
0
);
}
#[rstest]
#[case(false)]
#[case(true)]
#[tokio::test]
async fn test_position_check_matching_hedge_reports_is_order_invariant(
#[case] reverse_reports: bool,
) {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
ctx.add_instrument(instrument.clone());
let pos_long = create_test_position(
&instrument,
PositionId::from("P-MATCH-LONG"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let pos_short = create_test_position(
&instrument,
PositionId::from("P-MATCH-SHORT"),
OrderSide::Sell,
"2.0",
"3100.00",
);
ctx.add_position(&pos_long);
ctx.add_position(&pos_short);
let report_long = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"5.0",
"P-MATCH-LONG",
dec!(3000.00),
);
let report_short = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
"2.0",
"P-MATCH-SHORT",
dec!(3100.00),
);
let mut reports = vec![report_long, report_short];
if reverse_reports {
reports.reverse();
}
let mock_client = MockPositionExecutionClient::new(vec![], reports);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
0,
);
}
#[tokio::test]
async fn test_position_check_equal_net_with_mismatched_hedge_legs_is_discrepant() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let pos_long = create_test_position(
&instrument,
PositionId::from("P-NET-MISMATCH-LONG"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let pos_short = create_test_position(
&instrument,
PositionId::from("P-NET-MISMATCH-SHORT"),
OrderSide::Sell,
"2.0",
"3100.00",
);
let report_long = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"4.0",
"P-NET-MISMATCH-LONG",
dec!(3000.00),
);
let report_short = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
"1.0",
"P-NET-MISMATCH-SHORT",
dec!(3100.00),
);
ctx.add_instrument(instrument);
ctx.add_position(&pos_long);
ctx.add_position(&pos_short);
let mock_client = MockPositionExecutionClient::new(vec![], vec![report_long, report_short]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
1,
);
}
#[tokio::test]
async fn test_position_check_venue_only_offset_hedge_legs_are_discrepant() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let report_long = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"2.0",
"P-OFFSET-LONG",
dec!(3000.00),
);
let report_short = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
"2.0",
"P-OFFSET-SHORT",
dec!(3100.00),
);
ctx.add_instrument(instrument);
let mock_client = MockPositionExecutionClient::new(vec![], vec![report_long, report_short]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
1,
);
}
#[tokio::test]
async fn test_position_check_flat_and_nonflat_reports_use_nonflat_report() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position = create_test_position(
&instrument,
PositionId::from("P-NONFLAT"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let nonflat_report = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"5.0",
"P-NONFLAT",
dec!(3000.00),
);
let flat_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Flat,
Quantity::from("0"),
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
None,
None,
);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![nonflat_report, flat_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
0,
);
}
#[tokio::test]
async fn test_position_check_multi_leg_discrepancy_defers_reconciliation() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let pos_long = create_test_position(
&instrument,
PositionId::from("P-DEFER-LONG"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let pos_short = create_test_position(
&instrument,
PositionId::from("P-DEFER-SHORT"),
OrderSide::Sell,
"2.0",
"3100.00",
);
let report_long = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"5.0",
"P-DEFER-LONG",
dec!(3000.00),
);
let report_short = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
"1.0",
"P-DEFER-SHORT",
dec!(3100.00),
);
ctx.add_instrument(instrument);
ctx.add_position(&pos_long);
ctx.add_position(&pos_short);
let mock_client = MockPositionExecutionClient::new(vec![], vec![report_long, report_short]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
1,
);
}
#[tokio::test]
async fn test_position_check_single_leg_gets_fresh_budget_after_multi_leg_exhaustion() {
let config = ExecutionManagerConfig {
position_check_retries: 1,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let pos_long = create_test_position(
&instrument,
PositionId::from("P-SHAPE-LONG"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let pos_short = create_test_position(
&instrument,
PositionId::from("P-SHAPE-SHORT"),
OrderSide::Sell,
"2.0",
"3100.00",
);
ctx.add_instrument(instrument);
ctx.add_position(&pos_long);
ctx.add_position(&pos_short);
let report_long = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"4.0",
"P-SHAPE-LONG",
dec!(3000.00),
);
let report_short = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Short,
"2.0",
"P-SHAPE-SHORT",
dec!(3100.00),
);
let multi_leg_client =
MockPositionExecutionClient::new(vec![], vec![report_long, report_short]);
let clients: Vec<&dyn ExecutionClient> = vec![&multi_leg_client];
let first_events = ctx.manager.check_positions_consistency(&clients).await;
assert!(first_events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
1,
);
let single_report = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"4.0",
"P-SHAPE-NET",
dec!(3200.00),
);
let single_leg_client = MockPositionExecutionClient::new(vec![], vec![single_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&single_leg_client];
let second_events = ctx.manager.check_positions_consistency(&clients).await;
let fills = second_events
.iter()
.filter_map(|event| match event {
OrderEventAny::Filled(fill) => Some(fill),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].account_id, test_account_id());
assert_eq!(fills[0].order_side, OrderSide::Buy);
assert_eq!(fills[0].last_qty, Quantity::from("1.0"));
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, test_account_id())),
0,
);
}
#[tokio::test]
async fn test_position_check_multi_leg_reports_remain_isolated_by_account() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_a = AccountId::from("BINANCE-HEDGE-A");
let account_b = AccountId::from("BINANCE-HEDGE-B");
ctx.add_instrument(instrument.clone());
ctx.add_margin_account(account_a);
ctx.add_margin_account(account_b);
for (position_id, side, qty, price, account_id) in [
("P-A-LONG", OrderSide::Buy, "5.0", "3000.00", account_a),
("P-A-SHORT", OrderSide::Sell, "2.0", "3100.00", account_a),
("P-B-LONG", OrderSide::Buy, "7.0", "3200.00", account_b),
("P-B-SHORT", OrderSide::Sell, "3.0", "3300.00", account_b),
] {
let position = create_test_position_for_account(
&instrument,
PositionId::from(position_id),
side,
qty,
price,
account_id,
);
ctx.add_position(&position);
}
let client_a = MockPositionExecutionClient::configured(
ClientId::from("BINANCE-HEDGE-A"),
account_a,
test_venue(),
IndexSet::from([instrument_id.venue]),
false,
)
.with_position_reports(vec![
create_test_position_report(
account_a,
instrument_id,
PositionSideSpecified::Long,
"5.0",
"P-A-LONG",
dec!(3000.00),
),
create_test_position_report(
account_a,
instrument_id,
PositionSideSpecified::Short,
"2.0",
"P-A-SHORT",
dec!(3100.00),
),
]);
let client_b = MockPositionExecutionClient::configured(
ClientId::from("BINANCE-HEDGE-B"),
account_b,
test_venue(),
IndexSet::from([instrument_id.venue]),
false,
)
.with_position_reports(vec![
create_test_position_report(
account_b,
instrument_id,
PositionSideSpecified::Long,
"7.0",
"P-B-LONG",
dec!(3200.00),
),
create_test_position_report(
account_b,
instrument_id,
PositionSideSpecified::Short,
"2.0",
"P-B-SHORT",
dec!(3300.00),
),
]);
let clients: Vec<&dyn ExecutionClient> = vec![&client_a, &client_b];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_a)),
0,
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_b)),
1,
);
}
#[tokio::test]
async fn test_position_check_single_report_reconciliation_is_unchanged() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let position = create_test_position(
&instrument,
PositionId::from("P-SINGLE"),
OrderSide::Buy,
"3.0",
"3000.00",
);
let report = create_test_position_report(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
"5.0",
"P-SINGLE",
dec!(3100.00),
);
ctx.add_instrument(instrument);
ctx.add_position(&position);
let mock_client = MockPositionExecutionClient::new(vec![], vec![report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
let fills = events
.iter()
.filter_map(|event| match event {
OrderEventAny::Filled(fill) => Some(fill),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].account_id, test_account_id());
assert_eq!(fills[0].order_side, OrderSide::Buy);
assert_eq!(fills[0].last_qty, Quantity::from("2.0"));
assert_eq!(fills[0].last_px, Price::from("3250.00"));
}
fn create_test_position_report(
account_id: AccountId,
instrument_id: InstrumentId,
position_side: PositionSideSpecified,
quantity: &str,
venue_position_id: &str,
avg_px_open: Decimal,
) -> PositionStatusReport {
PositionStatusReport::new(
account_id,
instrument_id,
position_side,
Quantity::from(quantity),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
Some(PositionId::from(venue_position_id)),
Some(avg_px_open),
)
}
#[tokio::test]
async fn test_position_check_dedup_skips_second_hedge_position_same_instrument() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let pos_long = create_test_position(
&instrument,
PositionId::from("P-LONG"),
OrderSide::Buy,
"5.0",
"3000.00",
);
let pos_short = create_test_position(
&instrument,
PositionId::from("P-SHORT"),
OrderSide::Sell,
"3.0",
"3100.00",
);
ctx.add_position(&pos_long);
ctx.add_position(&pos_short);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
ctx.manager.check_positions_consistency(&clients).await;
ctx.manager.check_positions_consistency(&clients).await;
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(events.is_empty());
ctx.add_instrument(instrument.clone());
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
events.is_empty(),
"Expected retries to be exhausted after 3 cycles with dedup"
);
}
#[tokio::test]
async fn test_position_check_flat_venue_report_does_not_protect_stale_counter() {
let config = ExecutionManagerConfig {
position_check_retries: 1,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::from("P-001"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&position);
let flat_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Flat,
Quantity::from("0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
None,
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![flat_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(!events.is_empty());
ctx.manager.check_positions_consistency(&clients).await;
let close_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Sell)
.quantity(Quantity::from("5.0"))
.build();
let close_fill = TestOrderEventStubs::filled(
&close_order,
&instrument,
Some(TradeId::new("T-CLOSE-001")),
Some(PositionId::from("P-001")),
Some(Price::from("3000.00")),
Some(Quantity::from("5.0")),
None,
None,
None,
Some(test_account_id()),
);
let close_filled: OrderFilled = close_fill.into();
let mut pos = position;
pos.apply(&close_filled);
ctx.cache.borrow_mut().update_position(&pos).unwrap();
ctx.manager.check_positions_consistency(&clients).await;
let position2 = create_test_position(
&instrument,
PositionId::from("P-002"),
OrderSide::Buy,
"3.0",
"3100.00",
);
ctx.add_position(&position2);
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
!events.is_empty(),
"Expected events: flat venue report should not protect stale retry counter"
);
}
#[tokio::test]
async fn test_position_check_nonflat_venue_report_protects_counter() {
let config = ExecutionManagerConfig {
position_check_retries: 1,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
ctx.add_instrument(instrument.clone());
let position = create_test_position(
&instrument,
PositionId::from("P-001"),
OrderSide::Buy,
"5.0",
"3000.00",
);
ctx.add_position(&position);
let venue_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("3.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report.clone()]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(!events.is_empty());
ctx.manager.check_positions_consistency(&clients).await;
let close_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Sell)
.quantity(Quantity::from("5.0"))
.build();
let close_fill = TestOrderEventStubs::filled(
&close_order,
&instrument,
Some(TradeId::new("T-CLOSE-002")),
Some(PositionId::from("P-001")),
Some(Price::from("3000.00")),
Some(Quantity::from("5.0")),
None,
None,
None,
Some(test_account_id()),
);
let close_filled: OrderFilled = close_fill.into();
let mut pos = position;
pos.apply(&close_filled);
ctx.cache.borrow_mut().update_position(&pos).unwrap();
ctx.manager.check_positions_consistency(&clients).await;
let position2 = create_test_position(
&instrument,
PositionId::from("P-002"),
OrderSide::Buy,
"3.0",
"3100.00",
);
ctx.add_position(&position2);
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
events.is_empty(),
"Expected no events: non-flat venue report should protect retry counter"
);
}
#[tokio::test]
async fn test_position_check_retries_independent_per_account() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_a = AccountId::from("BINANCE-A");
let account_b = AccountId::from("BINANCE-B");
ctx.add_margin_account(account_a);
ctx.add_margin_account(account_b);
let pos_a = create_test_position_for_account(
&instrument,
PositionId::from("P-RETRY-A"),
OrderSide::Buy,
"5.0",
"3000.00",
account_a,
);
let pos_b = create_test_position_for_account(
&instrument,
PositionId::from("P-RETRY-B"),
OrderSide::Buy,
"3.0",
"3100.00",
account_b,
);
ctx.add_position(&pos_a);
ctx.add_position(&pos_b);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
ctx.manager.check_positions_consistency(&clients).await;
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_a)),
1,
"account A retry not incremented",
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_b)),
1,
"account B retry not incremented (would be 0 if dedup collapsed by instrument)",
);
}
#[tokio::test]
async fn test_position_check_activity_throttle_independent_per_account() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 60_000_000_000, ..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_a = AccountId::from("BINANCE-A");
let account_b = AccountId::from("BINANCE-B");
ctx.add_margin_account(account_a);
ctx.add_margin_account(account_b);
ctx.add_instrument(instrument.clone());
let pos_b = create_test_position_for_account(
&instrument,
PositionId::from("P-ACT-B"),
OrderSide::Buy,
"5.0",
"3000.00",
account_b,
);
ctx.add_position(&pos_b);
ctx.manager
.record_position_activity(instrument_id, account_a);
let mock_client = MockPositionExecutionClient::new(vec![], vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
let mut accounts_with_filled: HashSet<AccountId> = HashSet::new();
for event in &events {
if let OrderEventAny::Filled(fill) = event {
accounts_with_filled.insert(fill.account_id);
}
}
assert!(
accounts_with_filled.contains(&account_b),
"B's reconciliation must not be throttled by activity recorded for A",
);
}
#[tokio::test]
async fn test_position_check_grace_survives_accelerated_trading_clock() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 60_000_000_000, ..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account = AccountId::from("BINANCE-A");
ctx.add_instrument(instrument.clone());
ctx.add_margin_account(account);
let ts_event = UnixNanos::from(1_000_000_000);
let fill_report = FillReport::new(
account,
instrument_id,
VenueOrderId::from("V-1"),
TradeId::from("T-1"),
OrderSide::Buy,
Quantity::from("0.06"),
Price::from("3000.00"),
Money::new(0.0, Currency::USDT()),
LiquiditySide::Taker,
Some(ClientOrderId::from("O-1")),
None, ts_event, ts_event, None, );
ctx.manager
.observe_execution_report(&ExecutionReport::Fill(Box::new(fill_report)));
let accelerated_jump_ns: u64 = 954 * 86_400 * 1_000_000_000; ctx.advance_time(accelerated_jump_ns);
let venue_report = PositionStatusReport::new(
account,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("0.06"),
UnixNanos::from(accelerated_jump_ns),
UnixNanos::from(accelerated_jump_ns),
None, None, Some(dec!(3000.00)),
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
events.is_empty(),
"grace must survive an accelerated trading clock (it is measured on the \
monotonic clock); was {} reconciliation event(s), the grace regressed \
to self.clock",
events.len(),
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account)),
0,
"grace path must return before the retry counter is touched",
);
}
#[cfg_attr(
not(all(feature = "simulation", madsim)),
tokio::test(start_paused = true)
)]
#[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
async fn test_position_check_grace_expires_on_monotonic_clock() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 60_000_000_000,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account = AccountId::from("BINANCE-A");
ctx.add_instrument(instrument);
ctx.add_margin_account(account);
let ts_event = UnixNanos::from(1_000_000_000);
let fill_report = FillReport::new(
account,
instrument_id,
VenueOrderId::from("V-EXPIRY"),
TradeId::from("T-EXPIRY"),
OrderSide::Buy,
Quantity::from("0.06"),
Price::from("3000.00"),
Money::new(0.0, Currency::USDT()),
LiquiditySide::Taker,
Some(ClientOrderId::from("O-EXPIRY")),
None,
ts_event,
ts_event,
None,
);
ctx.manager
.observe_execution_report(&ExecutionReport::Fill(Box::new(fill_report)));
advance_clock(dst::time::Duration::from_secs(61)).await;
let venue_report = PositionStatusReport::new(
account,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("0.06"),
ts_event,
ts_event,
None,
None,
Some(dec!(3000.00)),
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![venue_report]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
assert!(
events
.iter()
.any(|event| matches!(event, OrderEventAny::Filled(_))),
"position discrepancy should fire after the monotonic grace expires",
);
}
#[tokio::test]
async fn test_check_positions_consistency_processes_only_discrepant_account() {
let config = ExecutionManagerConfig {
position_check_retries: 3,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_a = AccountId::from("BINANCE-A");
let account_b = AccountId::from("BINANCE-B");
ctx.add_margin_account(account_a);
ctx.add_margin_account(account_b);
ctx.add_instrument(instrument.clone());
let pos_a = create_test_position_for_account(
&instrument,
PositionId::from("P-E2E-A"),
OrderSide::Buy,
"5.0",
"3000.00",
account_a,
);
let pos_b = create_test_position_for_account(
&instrument,
PositionId::from("P-E2E-B"),
OrderSide::Buy,
"3.0",
"3100.00",
account_b,
);
ctx.add_position(&pos_a);
ctx.add_position(&pos_b);
let report_a = PositionStatusReport::new(
account_a,
instrument_id,
PositionSideSpecified::Long,
Quantity::from("5.0"),
UnixNanos::from(1_000_000),
UnixNanos::from(1_000_000),
None,
None,
Some(dec!(3000.00)),
);
let mock_client = MockPositionExecutionClient::new(vec![], vec![report_a]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
let events = ctx.manager.check_positions_consistency(&clients).await;
let mut accounts_with_filled: HashSet<AccountId> = HashSet::new();
for event in &events {
if let OrderEventAny::Filled(fill) = event {
accounts_with_filled.insert(fill.account_id);
}
}
assert!(
accounts_with_filled.contains(&account_b),
"expected reconciliation events for the discrepant account B",
);
assert!(
!accounts_with_filled.contains(&account_a),
"account A is not discrepant and must not be reconciled",
);
}
#[tokio::test]
async fn test_position_check_stale_retries_pruned_per_account() {
let config = ExecutionManagerConfig {
position_check_retries: 5,
position_check_threshold_ns: 0,
..Default::default()
};
let mut ctx = TestContext::with_config(config);
let instrument = test_instrument();
let instrument_id = instrument.id();
let account_a = AccountId::from("BINANCE-A");
let account_b = AccountId::from("BINANCE-B");
ctx.add_margin_account(account_a);
ctx.add_margin_account(account_b);
let pos_a = create_test_position_for_account(
&instrument,
PositionId::from("P-STALE-A"),
OrderSide::Buy,
"5.0",
"3000.00",
account_a,
);
let pos_b = create_test_position_for_account(
&instrument,
PositionId::from("P-STALE-B"),
OrderSide::Buy,
"3.0",
"3100.00",
account_b,
);
ctx.add_position(&pos_a);
ctx.add_position(&pos_b);
let mock_client = MockExecutionClient::new(vec![]);
let clients: Vec<&dyn ExecutionClient> = vec![&mock_client];
ctx.manager.check_positions_consistency(&clients).await;
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_a)),
1,
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_b)),
1,
);
let close_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument_id)
.side(OrderSide::Sell)
.quantity(Quantity::from("3.0"))
.build();
let close_fill = TestOrderEventStubs::filled(
&close_order,
&instrument,
Some(TradeId::new("T-CLOSE-B")),
Some(PositionId::from("P-STALE-B")),
Some(Price::from("3100.00")),
Some(Quantity::from("3.0")),
None,
None,
None,
Some(account_b),
);
let close_filled: OrderFilled = close_fill.into();
let mut pos_b = pos_b;
pos_b.apply(&close_filled);
ctx.cache.borrow_mut().update_position(&pos_b).unwrap();
ctx.manager.check_positions_consistency(&clients).await;
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_a)),
2,
"account A's counter must be retained while it remains active",
);
assert_eq!(
ctx.manager
.position_recon_retry_count(&(instrument_id, account_b)),
0,
"account B's counter must be pruned once its position is closed",
);
}
#[tokio::test]
async fn test_reconcile_mass_status_publishes_raw_reports_for_capture() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let order_topic = MessagingSwitchboard::reconciliation_raw_order_status_report_topic();
let fill_topic = MessagingSwitchboard::reconciliation_raw_fill_report_topic();
let position_topic = MessagingSwitchboard::reconciliation_raw_position_status_report_topic();
let (order_handler, order_saver) = get_any_saving_handler::<OrderStatusReport>(None);
let (fill_handler, fill_saver) = get_any_saving_handler::<FillReport>(None);
let (position_handler, position_saver) = get_any_saving_handler::<PositionStatusReport>(None);
let order_pattern: msgbus::MStr<msgbus::Pattern> = order_topic.into();
let fill_pattern: msgbus::MStr<msgbus::Pattern> = fill_topic.into();
let position_pattern: msgbus::MStr<msgbus::Pattern> = position_topic.into();
msgbus::subscribe_any(order_pattern, order_handler.clone(), None);
msgbus::subscribe_any(fill_pattern, fill_handler.clone(), None);
msgbus::subscribe_any(position_pattern, position_handler.clone(), None);
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let order_report = create_order_status_report(
None,
VenueOrderId::from("V-RAW-CAPTURE"),
instrument_id,
OrderStatus::Accepted,
Quantity::from("1.0"),
Quantity::from("0"),
);
mass_status.add_order_reports(vec![order_report.clone()]);
let fill_report = FillReport::new(
test_account_id(),
instrument_id,
VenueOrderId::from("V-RAW-CAPTURE"),
TradeId::from("T-RAW-CAPTURE"),
OrderSide::Buy,
Quantity::from("1.0"),
Price::from("3000.00"),
Money::new(0.0, Currency::USDT()),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
);
mass_status.add_fill_reports(vec![fill_report.clone()]);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("1.0"),
UnixNanos::from(3_000_000),
UnixNanos::from(3_000_000),
None,
None,
Some(dec!(3000.0)),
);
mass_status.add_position_reports(vec![position_report.clone()]);
let _ = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
msgbus::unsubscribe_any(order_pattern, &order_handler);
msgbus::unsubscribe_any(fill_pattern, &fill_handler);
msgbus::unsubscribe_any(position_pattern, &position_handler);
let orders = order_saver.get_messages();
assert_eq!(
orders.len(),
1,
"raw OrderStatusReport must be published once"
);
assert_eq!(orders[0], order_report);
let fills = fill_saver.get_messages();
assert_eq!(fills.len(), 1, "raw FillReport must be published once");
assert_eq!(fills[0], fill_report);
let positions = position_saver.get_messages();
assert_eq!(
positions.len(),
1,
"raw PositionStatusReport must be published once",
);
assert_eq!(positions[0], position_report);
}
#[tokio::test]
async fn test_reconcile_mass_status_does_not_capture_synthetic_reports() {
let mut ctx = TestContext::new();
let instrument_id = test_instrument_id();
ctx.add_instrument(test_instrument());
let order_topic = MessagingSwitchboard::reconciliation_raw_order_status_report_topic();
let fill_topic = MessagingSwitchboard::reconciliation_raw_fill_report_topic();
let order_pattern: msgbus::MStr<msgbus::Pattern> = order_topic.into();
let fill_pattern: msgbus::MStr<msgbus::Pattern> = fill_topic.into();
let (order_handler, order_saver) = get_any_saving_handler::<OrderStatusReport>(None);
let (fill_handler, fill_saver) = get_any_saving_handler::<FillReport>(None);
msgbus::subscribe_any(order_pattern, order_handler.clone(), None);
msgbus::subscribe_any(fill_pattern, fill_handler.clone(), None);
let venue_order_id = VenueOrderId::from("V-SYN-RAW");
let mut mass_status = ExecutionMassStatus::new(
test_client_id(),
test_account_id(),
test_venue(),
UnixNanos::default(),
Some(UUID4::new()),
);
let order_report = create_order_status_report(
None,
venue_order_id,
instrument_id,
OrderStatus::Filled,
Quantity::from("1.0"),
Quantity::from("0.4"),
);
mass_status.add_order_reports(vec![order_report.clone()]);
let fill_report = FillReport::new(
test_account_id(),
instrument_id,
venue_order_id,
TradeId::from("T-SYN-RAW"),
OrderSide::Buy,
Quantity::from("0.4"),
Price::from("3000.00"),
Money::new(0.0, Currency::USDT()),
LiquiditySide::Maker,
None,
None,
UnixNanos::from(2_000_000),
UnixNanos::from(2_000_000),
None,
);
mass_status.add_fill_reports(vec![fill_report.clone()]);
let position_report = PositionStatusReport::new(
test_account_id(),
instrument_id,
PositionSideSpecified::Long,
Quantity::from("1.0"),
UnixNanos::from(3_000_000),
UnixNanos::from(3_000_000),
None,
None, Some(dec!(3000.0)),
);
mass_status.add_position_reports(vec![position_report]);
let _ = ctx
.manager
.reconcile_execution_mass_status(mass_status, ctx.exec_engine.clone())
.await;
msgbus::unsubscribe_any(order_pattern, &order_handler);
msgbus::unsubscribe_any(fill_pattern, &fill_handler);
let orders = order_saver.get_messages();
assert_eq!(
orders.len(),
1,
"only the raw venue OrderStatusReport must be captured; \
synthetic reports from adjust_mass_status_fills must not reach \
the raw topic",
);
assert_eq!(orders[0], order_report);
assert_eq!(
orders[0].venue_order_id, venue_order_id,
"captured venue_order_id must match the original raw input, not a synthetic `S-` id",
);
let fills = fill_saver.get_messages();
assert_eq!(
fills.len(),
1,
"only the raw venue FillReport must be captured; the synthetic \
opening fill inserted by adjustment must not appear on the raw topic",
);
assert_eq!(fills[0], fill_report);
assert_eq!(
fills[0].trade_id,
TradeId::from("T-SYN-RAW"),
"captured trade_id must match the original raw input, not a synthetic `S-` id",
);
}