#![allow(clippy::unwrap_used, clippy::expect_used)]
use chrono::{DateTime, TimeDelta, Utc};
use fnv::FnvHashMap;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use rustrade::{
EngineEvent, Sequence, Timed,
engine::{
Engine, EngineOutput, Processor,
action::{
ActionOutput,
generate_algo_orders::GenerateAlgoOrdersOutput,
send_requests::{SendCancelsAndOpensOutput, SendRequestsOutput},
},
audit::EngineAudit,
clock::HistoricalClock,
command::Command,
execution_tx::MultiExchangeTxMap,
process_with_audit,
state::{
EngineState,
asset::AssetStates,
connectivity::Health,
global::DefaultGlobalData,
instrument::{
data::{DefaultInstrumentMarketData, InstrumentDataState},
filter::InstrumentFilter,
},
position::{OmsMode, PositionExited},
trading::TradingState,
},
},
execution::{AccountStreamEvent, request::ExecutionRequest},
risk::DefaultRiskManager,
statistic::time::Annual365,
strategy::{
algo::AlgoStrategy,
close_positions::{ClosePositionsStrategy, close_open_positions_with_market_orders},
on_disconnect::OnDisconnectStrategy,
on_trading_disabled::OnTradingDisabled,
},
test_utils::time_plus_days,
};
use rustrade_data::{
event::{DataKind, MarketEvent},
streams::consumer::MarketStreamEvent,
subscription::trade::PublicTrade,
};
use rustrade_execution::{
AccountEvent, AccountEventKind, AccountSnapshot, FeeModelConfig, PerContractFeeModel,
balance::{AssetBalance, Balance},
order::{
Order, OrderKey, OrderKind, TimeInForce,
id::{ClientOrderId, OrderId, PositionId, StrategyId},
request::{OrderRequestCancel, OrderRequestOpen, OrderResponseCancel, RequestOpen},
state::{ActiveOrderState, Cancelled, Filled, Open, OrderState},
},
trade::{AssetFees, Trade, TradeId},
};
use rustrade_instrument::{
Side, Underlying,
asset::AssetIndex,
exchange::{ExchangeId, ExchangeIndex},
index::IndexedInstruments,
instrument::{
Instrument, InstrumentIndex,
kind::{
InstrumentKind,
option::{OptionContract, OptionExercise, OptionKind},
},
spec::{
InstrumentSpec, InstrumentSpecNotional, InstrumentSpecPrice, InstrumentSpecQuantity,
OrderQuantityUnits,
},
},
};
use rustrade_integration::{
channel::{UnboundedTx, mpsc_unbounded},
collection::{none_one_or_many::NoneOneOrMany, one_or_many::OneOrMany, snapshot::Snapshot},
};
const STARTING_TIMESTAMP: DateTime<Utc> = DateTime::<Utc>::MIN_UTC;
const RISK_FREE_RETURN: Decimal = dec!(0.05);
const STARTING_BALANCE_USDT: Balance = Balance {
total: dec!(40_000.0),
free: dec!(40_000.0),
};
const STARTING_BALANCE_BTC: Balance = Balance {
total: dec!(1.0),
free: dec!(1.0),
};
const STARTING_BALANCE_ETH: Balance = Balance {
total: dec!(10.0),
free: dec!(10.0),
};
const QUOTE_FEES_PERCENT: f64 = 0.1;
fn quote_asset_index(instrument: usize) -> AssetIndex {
match instrument {
0 => AssetIndex(2), 1 => AssetIndex(0), other => panic!(
"quote_asset_index: unknown instrument index {other}; update test setup if a new instrument is added"
),
}
}
fn asset_fees(instrument: usize, amount: Decimal) -> AssetFees<AssetIndex> {
AssetFees::new(quote_asset_index(instrument), amount, Some(amount))
}
type TestEngine = Engine<
HistoricalClock,
EngineState<DefaultGlobalData, DefaultInstrumentMarketData>,
MultiExchangeTxMap<UnboundedTx<ExecutionRequest>>,
TestBuyAndHoldStrategy,
DefaultRiskManager<EngineState<DefaultGlobalData, DefaultInstrumentMarketData>>,
>;
#[test]
fn test_engine_process_engine_event_with_audit() {
let (execution_tx, mut execution_rx) = mpsc_unbounded();
let mut engine = build_engine(TradingState::Disabled, execution_tx);
assert_eq!(engine.meta.sequence, Sequence(0));
assert_eq!(engine.state.connectivity.global, Health::Reconnecting);
let event = account_event_snapshot(&engine.state.assets);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(0));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(engine.state.connectivity.global, Health::Reconnecting);
let event = market_event_trade(1, 0, dec!(10_000));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(1));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(engine.state.connectivity.global, Health::Healthy);
let event = market_event_trade(1, 1, dec!(0.1));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(2));
assert_eq!(audit.event, EngineAudit::process(event));
let event = EngineEvent::TradingStateUpdate(TradingState::Enabled);
let audit = process_with_audit(&mut engine, event);
assert_eq!(audit.context.sequence, Sequence(3));
let btc_usdt_buy_order = OrderRequestOpen {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid: gen_cid(0),
},
state: RequestOpen {
side: Side::Buy,
kind: OrderKind::Market,
time_in_force: TimeInForce::ImmediateOrCancel,
price: None,
quantity: dec!(1),
position_id: None,
reduce_only: false,
},
};
let eth_btc_buy_order = OrderRequestOpen {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(1),
strategy: strategy_id(),
cid: gen_cid(1),
},
state: RequestOpen {
side: Side::Buy,
kind: OrderKind::Market,
time_in_force: TimeInForce::ImmediateOrCancel,
price: None,
quantity: dec!(1),
position_id: None,
reduce_only: false,
},
};
assert_eq!(
audit.event,
EngineAudit::process_with_output(
EngineEvent::TradingStateUpdate(TradingState::Enabled),
EngineOutput::AlgoOrders(GenerateAlgoOrdersOutput {
cancels_and_opens: SendCancelsAndOpensOutput {
cancels: SendRequestsOutput::default(),
opens: SendRequestsOutput {
sent: NoneOneOrMany::Many(vec![
btc_usdt_buy_order.clone(),
eth_btc_buy_order.clone(),
]),
errors: NoneOneOrMany::None,
},
},
..Default::default()
})
)
);
assert_eq!(
execution_rx.next().unwrap(),
ExecutionRequest::Open(btc_usdt_buy_order)
);
assert_eq!(
execution_rx.next().unwrap(),
ExecutionRequest::Open(eth_btc_buy_order)
);
let event = EngineEvent::TradingStateUpdate(TradingState::Disabled);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(4));
assert_eq!(
audit.event,
EngineAudit::process_with_output(
event,
EngineOutput::OnTradingDisabled(OnTradingDisabledOutput)
)
);
let event = account_event_order_response(0, 2, Side::Buy, 1.0, 1.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(5));
assert_eq!(audit.event, EngineAudit::process(event));
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.orders
.0
.is_empty()
);
let event = account_event_trade(0, 2, Side::Buy, 10_000.0, 1.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(6));
assert_eq!(audit.event, EngineAudit::process(event));
let event = account_event_balance(2, 2, 9_000.0, 9_000.0); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(7));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(2))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(9_000.0), dec!(9_000.0)),
time_plus_days(STARTING_TIMESTAMP, 2)
)
);
let event = account_event_balance(0, 2, 2.0, 2.0); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(8));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(0))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(2.0), dec!(2.0)),
time_plus_days(STARTING_TIMESTAMP, 2)
)
);
let event = account_event_order_response(1, 2, Side::Buy, 1.0, 1.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(9));
assert_eq!(audit.event, EngineAudit::process(event));
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(1))
.orders
.0
.is_empty()
);
let event = account_event_trade(1, 2, Side::Buy, 0.1, 1.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(10));
assert_eq!(audit.event, EngineAudit::process(event));
let event = account_event_balance(0, 2, 0.99, 0.99); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(11));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(0))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(0.99), dec!(0.99)),
time_plus_days(STARTING_TIMESTAMP, 2)
)
);
let event = account_event_balance(1, 2, 11.0, 11.0); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(12));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(1))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(11.0), dec!(11.0)),
time_plus_days(STARTING_TIMESTAMP, 2)
)
);
let event = market_event_trade(2, 0, dec!(20_000));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(13));
assert_eq!(audit.event, EngineAudit::process(event));
let event = market_event_trade(2, 1, dec!(0.05));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(14));
assert_eq!(audit.event, EngineAudit::process(event));
let event = command_close_position(0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(15));
let btc_usdt_sell_order = OrderRequestOpen {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid: ClientOrderId::new("netting"),
},
state: RequestOpen {
side: Side::Sell,
kind: OrderKind::Market,
time_in_force: TimeInForce::ImmediateOrCancel,
price: None,
quantity: dec!(1),
position_id: Some(PositionId::NETTING),
reduce_only: true, },
};
assert_eq!(
audit.event,
EngineAudit::process_with_output(
event,
EngineOutput::Commanded(ActionOutput::ClosePositions(SendCancelsAndOpensOutput {
cancels: SendRequestsOutput::default(),
opens: SendRequestsOutput {
sent: NoneOneOrMany::One(btc_usdt_sell_order.clone()),
errors: NoneOneOrMany::None,
},
}))
)
);
assert_eq!(
execution_rx.next().unwrap(),
ExecutionRequest::Open(btc_usdt_sell_order)
);
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::OrderSnapshot(Snapshot(Order {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid: ClientOrderId::new("netting"),
},
side: Side::Sell,
price: None,
quantity: dec!(1),
kind: OrderKind::Market,
time_in_force: TimeInForce::ImmediateOrCancel,
state: OrderState::active(Open {
id: gen_order_id(0),
time_exchange: time_plus_days(STARTING_TIMESTAMP, 3),
filled_quantity: dec!(1),
}),
})),
}));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(16));
assert_eq!(audit.event, EngineAudit::process(event));
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.orders
.0
.is_empty()
);
let event = account_event_balance(2, 3, 27_000.0, 27_000.0); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(17));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(2))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(27_000.0), dec!(27_000.0)),
time_plus_days(STARTING_TIMESTAMP, 3)
)
);
let event = account_event_balance(0, 3, 1.0, 1.0); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(18));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(0))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(1.0), dec!(1.0)),
time_plus_days(STARTING_TIMESTAMP, 3)
)
);
let event = account_event_trade(0, 3, Side::Sell, 20_000.0, 1.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(19));
assert_eq!(
audit.event,
EngineAudit::process_with_output(
event,
PositionExited {
position_id: PositionId::NETTING,
instrument: InstrumentIndex(0),
side: Side::Buy,
price_entry_average: dec!(10_000.0),
quantity_abs_max: dec!(1.0),
pnl_realised: dec!(7000.0), fees_enter: asset_fees(0, dec!(1_000.0)),
fees_exit: asset_fees(0, dec!(2_000.0)),
time_enter: time_plus_days(STARTING_TIMESTAMP, 2),
time_exit: time_plus_days(STARTING_TIMESTAMP, 3),
trades: vec![gen_trade_id(0), gen_trade_id(0)],
}
)
);
let event = EngineEvent::Market(MarketStreamEvent::Reconnecting(ExchangeId::BinanceSpot));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(20));
assert_eq!(
audit.event,
EngineAudit::process_with_output(event, EngineOutput::MarketDisconnect(OnDisconnectOutput))
);
assert_eq!(engine.state.connectivity.global, Health::Reconnecting);
assert_eq!(
engine
.state
.connectivity
.connectivity(&ExchangeId::BinanceSpot)
.market_data,
Health::Reconnecting
);
assert_eq!(
engine
.state
.connectivity
.connectivity(&ExchangeId::BinanceSpot)
.account,
Health::Healthy
);
let eth_btc_sell_order = OrderRequestOpen {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(1),
strategy: strategy_id(),
cid: gen_cid(1),
},
state: RequestOpen {
side: Side::Sell,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: true },
price: Some(dec!(0.05)),
quantity: dec!(1),
position_id: None,
reduce_only: true, },
};
let event = EngineEvent::Command(Command::SendOpenRequests(OneOrMany::One(
eth_btc_sell_order.clone(),
)));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(21));
assert_eq!(
audit.event,
EngineAudit::process_with_output(
event,
EngineOutput::Commanded(ActionOutput::OpenOrders(SendRequestsOutput {
sent: NoneOneOrMany::One(eth_btc_sell_order.clone()),
errors: NoneOneOrMany::None,
}))
)
);
assert_eq!(
execution_rx.next().unwrap(),
ExecutionRequest::Open(eth_btc_sell_order)
);
let event = account_event_order_response(1, 4, Side::Sell, 1.0, 0.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(22));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(1))
.orders
.0
.len(),
1
);
assert_eq!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(1))
.orders
.0
.get(&gen_cid(1))
.unwrap(),
&Order {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(1),
strategy: strategy_id(),
cid: gen_cid(1),
},
side: Side::Sell,
price: Some(dec!(0.05)),
quantity: dec!(1),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: true },
state: ActiveOrderState::Open(Open {
id: gen_order_id(1),
time_exchange: time_plus_days(STARTING_TIMESTAMP, 4),
filled_quantity: dec!(0),
}),
}
);
let event = account_event_balance(1, 4, 11.0, 10.0); let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(23));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(1))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(11.0), dec!(10.0)),
time_plus_days(STARTING_TIMESTAMP, 4)
)
);
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::OrderSnapshot(Snapshot(Order {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(1),
strategy: strategy_id(),
cid: gen_cid(1),
},
side: Side::Sell,
price: Some(dec!(0.05)),
quantity: dec!(1),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: true },
state: OrderState::fully_filled(Filled::new(
OrderId::new("eth_btc_sell_order"),
time_plus_days(STARTING_TIMESTAMP, 4),
dec!(1),
None,
)),
})),
}));
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(24));
assert_eq!(audit.event, EngineAudit::process(event));
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(1))
.orders
.0
.is_empty()
);
let event = account_event_trade(1, 5, Side::Sell, 0.05, 1.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(25));
assert_eq!(
audit.event,
EngineAudit::process_with_output(
event,
PositionExited {
position_id: PositionId::NETTING,
instrument: InstrumentIndex(1),
side: Side::Buy,
price_entry_average: dec!(0.1),
quantity_abs_max: dec!(1.0),
pnl_realised: dec!(-0.065), fees_enter: asset_fees(1, dec!(0.01)), fees_exit: asset_fees(1, dec!(0.005)), time_enter: time_plus_days(STARTING_TIMESTAMP, 2),
time_exit: time_plus_days(STARTING_TIMESTAMP, 5),
trades: vec![gen_trade_id(1), gen_trade_id(1)],
}
)
);
let event = account_event_balance(1, 5, 10.0, 10.0);
let audit = process_with_audit(&mut engine, event.clone());
assert_eq!(audit.context.sequence, Sequence(26));
assert_eq!(audit.event, EngineAudit::process(event));
assert_eq!(
engine
.state
.assets
.asset_index(&AssetIndex(1))
.balance
.unwrap(),
Timed::new(
Balance::new(dec!(10.0), dec!(10.0)),
time_plus_days(STARTING_TIMESTAMP, 5)
)
);
let mut summary = engine.trading_summary_generator(RISK_FREE_RETURN);
summary.update_time_now(time_plus_days(STARTING_TIMESTAMP, 5));
assert_eq!(summary.risk_free_return, RISK_FREE_RETURN);
assert_eq!(
summary.time_engine_now,
time_plus_days(STARTING_TIMESTAMP, 5)
);
let btc_usdt_tear = summary.instruments.get_index(0).unwrap().1;
assert_eq!(btc_usdt_tear.pnl_returns.pnl_raw, dec!(7000.0));
let eth_btc_tear = summary.instruments.get_index(1).unwrap().1;
assert_eq!(eth_btc_tear.pnl_returns.pnl_raw, dec!(-0.065));
let trading_summary = summary.generate(Annual365);
assert_eq!(trading_summary.time_engine_start, summary.time_engine_start);
assert_eq!(trading_summary.time_engine_end, summary.time_engine_now);
let duration = trading_summary.trading_duration();
let five_days = TimeDelta::days(5);
let drift = (five_days - duration).abs();
assert!(
drift < TimeDelta::milliseconds(1),
"Expected ~5 days (within 1ms), got {:?} (drift: {:?})",
duration,
drift
);
let btc_usdt_sheet = trading_summary.instruments.get_index(0).unwrap().1;
assert_eq!(btc_usdt_sheet.pnl, dec!(7000.0));
let eth_btc_sheet = trading_summary.instruments.get_index(1).unwrap().1;
assert_eq!(eth_btc_sheet.pnl, dec!(-0.065));
}
struct TestBuyAndHoldStrategy {
id: StrategyId,
}
impl AlgoStrategy for TestBuyAndHoldStrategy {
type State = EngineState<DefaultGlobalData, DefaultInstrumentMarketData>;
fn generate_algo_orders(
&self,
state: &Self::State,
) -> (
impl IntoIterator<Item = OrderRequestCancel<ExchangeIndex, InstrumentIndex>>,
impl IntoIterator<Item = OrderRequestOpen<ExchangeIndex, InstrumentIndex>>,
) {
let opens = state
.instruments
.instruments(&InstrumentFilter::None)
.filter_map(|state| {
if !state.position.positions.is_empty() {
return None;
}
if !state.orders.0.is_empty() {
return None;
}
state.data.price()?;
Some(OrderRequestOpen {
key: OrderKey {
exchange: state.instrument.exchange,
instrument: state.key,
strategy: self.id.clone(),
cid: gen_cid(state.key.index()),
},
state: RequestOpen {
side: Side::Buy,
kind: OrderKind::Market,
time_in_force: TimeInForce::ImmediateOrCancel,
price: None, quantity: dec!(1),
position_id: None,
reduce_only: false,
},
})
});
(std::iter::empty(), opens)
}
}
fn strategy_id() -> StrategyId {
StrategyId::new("TestBuyAndHoldStrategy")
}
fn gen_cid(instrument: usize) -> ClientOrderId {
ClientOrderId::new(InstrumentIndex(instrument).to_string())
}
fn gen_trade_id(instrument: usize) -> TradeId {
TradeId::new(InstrumentIndex(instrument).to_string())
}
fn gen_order_id(instrument: usize) -> OrderId {
OrderId::new(InstrumentIndex(instrument).to_string())
}
impl ClosePositionsStrategy for TestBuyAndHoldStrategy {
type State = EngineState<DefaultGlobalData, DefaultInstrumentMarketData>;
fn close_positions_requests<'a>(
&'a self,
state: &'a Self::State,
filter: &'a InstrumentFilter<ExchangeIndex, AssetIndex, InstrumentIndex>,
) -> (
impl IntoIterator<Item = OrderRequestCancel<ExchangeIndex, InstrumentIndex>> + 'a,
impl IntoIterator<Item = OrderRequestOpen<ExchangeIndex, InstrumentIndex>> + 'a,
)
where
ExchangeIndex: 'a,
AssetIndex: 'a,
InstrumentIndex: 'a,
{
close_open_positions_with_market_orders(&self.id, state, filter, |_, pos_id| {
ClientOrderId::new(pos_id.0.as_str())
})
}
}
#[derive(Debug, PartialEq)]
struct OnDisconnectOutput;
impl
OnDisconnectStrategy<
HistoricalClock,
EngineState<DefaultGlobalData, DefaultInstrumentMarketData>,
MultiExchangeTxMap<UnboundedTx<ExecutionRequest>>,
DefaultRiskManager<EngineState<DefaultGlobalData, DefaultInstrumentMarketData>>,
> for TestBuyAndHoldStrategy
{
type OnDisconnect = OnDisconnectOutput;
fn on_disconnect(
_: &mut Engine<
HistoricalClock,
EngineState<DefaultGlobalData, DefaultInstrumentMarketData>,
MultiExchangeTxMap<UnboundedTx<ExecutionRequest>>,
Self,
DefaultRiskManager<EngineState<DefaultGlobalData, DefaultInstrumentMarketData>>,
>,
_: ExchangeId,
) -> Self::OnDisconnect {
OnDisconnectOutput
}
}
#[derive(Debug, PartialEq)]
struct OnTradingDisabledOutput;
impl
OnTradingDisabled<
HistoricalClock,
EngineState<DefaultGlobalData, DefaultInstrumentMarketData>,
MultiExchangeTxMap<UnboundedTx<ExecutionRequest>>,
DefaultRiskManager<EngineState<DefaultGlobalData, DefaultInstrumentMarketData>>,
> for TestBuyAndHoldStrategy
{
type OnTradingDisabled = OnTradingDisabledOutput;
fn on_trading_disabled(
_: &mut Engine<
HistoricalClock,
EngineState<DefaultGlobalData, DefaultInstrumentMarketData>,
MultiExchangeTxMap<UnboundedTx<ExecutionRequest>>,
Self,
DefaultRiskManager<EngineState<DefaultGlobalData, DefaultInstrumentMarketData>>,
>,
) -> Self::OnTradingDisabled {
OnTradingDisabledOutput
}
}
fn build_engine(
trading_state: TradingState,
execution_tx: UnboundedTx<ExecutionRequest>,
) -> TestEngine {
let instruments = IndexedInstruments::builder()
.add_instrument(Instrument::spot(
ExchangeId::BinanceSpot,
"binance_spot_btc_usdt",
"BTCUSDT",
Underlying::new("btc", "usdt"),
Some(InstrumentSpec::new(
InstrumentSpecPrice::new(dec!(0.01), dec!(0.01)),
InstrumentSpecQuantity::new(
OrderQuantityUnits::Quote,
dec!(0.00001),
dec!(0.00001),
),
InstrumentSpecNotional::new(dec!(5.0)),
)),
))
.add_instrument(Instrument::spot(
ExchangeId::BinanceSpot,
"binance_spot_eth_btc",
"ETHBTC",
Underlying::new("eth", "btc"),
Some(InstrumentSpec::new(
InstrumentSpecPrice::new(dec!(0.00001), dec!(0.00001)),
InstrumentSpecQuantity::new(OrderQuantityUnits::Quote, dec!(0.0001), dec!(0.0001)),
InstrumentSpecNotional::new(dec!(0.0001)),
)),
))
.build();
let clock = HistoricalClock::new(STARTING_TIMESTAMP);
let state = EngineState::builder(&instruments, DefaultGlobalData, |_| {
DefaultInstrumentMarketData::default()
})
.time_engine_start(STARTING_TIMESTAMP)
.trading_state(trading_state)
.balances([
(ExchangeId::BinanceSpot, "usdt", STARTING_BALANCE_USDT),
(ExchangeId::BinanceSpot, "btc", STARTING_BALANCE_BTC),
(ExchangeId::BinanceSpot, "eth", STARTING_BALANCE_ETH),
])
.build();
let initial_account = FnvHashMap::from(&state);
assert_eq!(initial_account.len(), 1);
let execution_txs =
MultiExchangeTxMap::from_iter([(ExchangeId::BinanceSpot, Some(execution_tx))]);
Engine::new(
clock,
state,
execution_txs,
TestBuyAndHoldStrategy { id: strategy_id() },
DefaultRiskManager::default(),
)
}
fn account_event_snapshot(assets: &AssetStates) -> EngineEvent<DataKind> {
EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::Snapshot(AccountSnapshot {
exchange: ExchangeIndex(0),
balances: assets
.0
.iter()
.enumerate()
.map(|(index, (_, state))| AssetBalance {
asset: AssetIndex(index),
balance: state.balance.unwrap().value,
time_exchange: state.balance.unwrap().time,
})
.collect(),
instruments: vec![],
}),
}))
}
fn market_event_trade(time_plus: u64, instrument: usize, price: Decimal) -> EngineEvent<DataKind> {
EngineEvent::Market(MarketStreamEvent::Item(MarketEvent {
time_exchange: time_plus_days(STARTING_TIMESTAMP, time_plus),
time_received: time_plus_days(STARTING_TIMESTAMP, time_plus),
exchange: ExchangeId::BinanceSpot,
instrument: InstrumentIndex(instrument),
kind: DataKind::Trade(PublicTrade {
id: time_plus.to_string().into(),
price,
amount: Decimal::ONE,
side: Some(Side::Buy),
}),
}))
}
fn account_event_order_response(
instrument: usize,
time_plus: u64,
side: Side,
quantity: f64,
filled: f64,
) -> EngineEvent<DataKind> {
EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::OrderSnapshot(Snapshot(Order {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(instrument),
strategy: strategy_id(),
cid: gen_cid(instrument),
},
side,
price: None, quantity: Decimal::try_from(quantity).unwrap(),
kind: OrderKind::Market,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: true },
state: OrderState::active(Open {
id: gen_order_id(instrument),
time_exchange: time_plus_days(STARTING_TIMESTAMP, time_plus),
filled_quantity: Decimal::try_from(filled).unwrap(),
}),
})),
}))
}
fn account_event_balance(
asset: usize,
time_plus: u64,
total: f64,
free: f64,
) -> EngineEvent<DataKind> {
EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::BalanceSnapshot(Snapshot(AssetBalance {
asset: AssetIndex(asset),
balance: Balance::new(
Decimal::try_from(total).unwrap(),
Decimal::try_from(free).unwrap(),
),
time_exchange: time_plus_days(STARTING_TIMESTAMP, time_plus),
})),
}))
}
fn account_event_trade(
instrument: usize,
time_plus: u64,
side: Side,
price: f64,
quantity: f64,
) -> EngineEvent<DataKind> {
EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::Trade(Trade {
id: gen_trade_id(instrument),
order_id: gen_order_id(instrument),
instrument: InstrumentIndex(instrument),
strategy: strategy_id(),
time_exchange: time_plus_days(STARTING_TIMESTAMP, time_plus),
side,
price: Decimal::try_from(price).unwrap(),
quantity: Decimal::try_from(quantity).unwrap(),
fees: asset_fees(
instrument,
Decimal::try_from(price * quantity * QUOTE_FEES_PERCENT).unwrap(),
),
}),
}))
}
fn command_close_position(instrument: usize) -> EngineEvent<DataKind> {
EngineEvent::Command(Command::ClosePositions(InstrumentFilter::Instruments(
OneOrMany::One(InstrumentIndex(instrument)),
)))
}
fn build_option_engine(
trading_state: TradingState,
execution_tx: UnboundedTx<ExecutionRequest>,
) -> TestEngine {
let expiry = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let instruments = IndexedInstruments::builder()
.add_instrument(Instrument::new(
ExchangeId::BinanceSpot,
"binance_btc_call_50k",
"BTC-50000-C",
Underlying::new("btc", "usd"),
rustrade_instrument::instrument::quote::InstrumentQuoteAsset::UnderlyingQuote,
InstrumentKind::Option(OptionContract {
contract_size: dec!(1),
settlement_asset: "usd".into(),
kind: OptionKind::Call,
exercise: OptionExercise::European,
expiry,
strike: dec!(50_000),
}),
None,
))
.add_instrument(Instrument::spot(
ExchangeId::BinanceSpot,
"binance_spot_btc_usd",
"BTCUSD",
Underlying::new("btc", "usd"),
None,
))
.build();
let clock = HistoricalClock::new(STARTING_TIMESTAMP);
let state = EngineState::builder(&instruments, DefaultGlobalData, |_| {
DefaultInstrumentMarketData::default()
})
.time_engine_start(STARTING_TIMESTAMP)
.trading_state(trading_state)
.balances([
(ExchangeId::BinanceSpot, "usd", STARTING_BALANCE_USDT),
(ExchangeId::BinanceSpot, "btc", STARTING_BALANCE_BTC),
])
.build();
let execution_txs =
MultiExchangeTxMap::from_iter([(ExchangeId::BinanceSpot, Some(execution_tx))]);
Engine::new(
clock,
state,
execution_txs,
TestBuyAndHoldStrategy { id: strategy_id() },
DefaultRiskManager::default(),
)
}
fn send_spot_price(engine: &mut TestEngine, instrument: usize, price: Decimal) {
let event = market_event_trade(1, instrument, price);
engine.process(event);
}
fn open_option_position(engine: &mut TestEngine, quantity: Decimal, price: Decimal) {
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::Trade(Trade {
id: TradeId::new("opt-trade-open"),
order_id: gen_order_id(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
time_exchange: time_plus_days(STARTING_TIMESTAMP, 1),
side: Side::Buy,
price,
quantity,
fees: AssetFees::new(AssetIndex(1), Decimal::ZERO, Some(Decimal::ZERO)),
}),
}));
engine.process(event);
}
#[test]
fn test_contract_expiry_otm_call() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(45_000));
open_option_position(&mut engine, dec!(2), dec!(1_000));
assert!(
!engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.position
.positions
.is_empty()
);
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited.len(), 1);
assert_eq!(exited[0].pnl_realised, dec!(-2_000));
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.position
.positions
.is_empty()
);
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.expiration_processed
);
}
#[test]
fn test_contract_expiry_itm_call() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(55_000));
open_option_position(&mut engine, dec!(1), dec!(2_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited.len(), 1);
assert_eq!(exited[0].pnl_realised, dec!(3_000));
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.position
.positions
.is_empty()
);
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.expiration_processed
);
}
#[test]
fn test_contract_expiry_idempotent() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(45_000));
open_option_position(&mut engine, dec!(1), dec!(1_000));
let exited_first = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited_first.len(), 1);
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.expiration_processed
);
let exited_second = engine.process_contract_expiry(&InstrumentIndex(0));
assert!(exited_second.is_empty());
}
#[test]
fn test_contract_expiry_no_position() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(45_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert!(exited.is_empty());
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.expiration_processed
);
}
#[test]
fn test_contract_expiry_missing_spot_price() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
open_option_position(&mut engine, dec!(1), dec!(1_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert!(exited.is_empty());
assert!(
!engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.expiration_processed
);
}
#[test]
fn test_contract_expiry_replica_state_cleared() {
use rustrade::{
engine::audit::state_replica::StateReplicaManager,
engine::audit::{AuditTick, EngineAudit, context::EngineContext},
};
use rustrade_integration::collection::none_one_or_many::NoneOneOrMany;
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(45_000));
open_option_position(&mut engine, dec!(1), dec!(1_000));
let expiry_event = EngineEvent::ContractExpiry(InstrumentIndex(0));
let audit_tick = process_with_audit(&mut engine, expiry_event.clone());
let (execution_tx2, _) = mpsc_unbounded();
let mut replica_engine = build_option_engine(TradingState::Disabled, execution_tx2);
send_spot_price(&mut replica_engine, 1, dec!(45_000));
open_option_position(&mut replica_engine, dec!(1), dec!(1_000));
let seed_context = EngineContext {
time: STARTING_TIMESTAMP,
sequence: Sequence(0),
};
let seed_tick: AuditTick<_, EngineContext> = AuditTick {
event: replica_engine.state.clone(),
context: seed_context,
};
#[allow(clippy::type_complexity)]
let dummy_updates: std::iter::Empty<
AuditTick<
EngineAudit<
EngineEvent<DataKind>,
EngineOutput<OnTradingDisabledOutput, OnDisconnectOutput>,
>,
>,
> = std::iter::empty();
let mut replica_manager = StateReplicaManager::new(seed_tick, dummy_updates);
let outputs: NoneOneOrMany<EngineOutput<OnTradingDisabledOutput, OnDisconnectOutput>> =
match &audit_tick.event {
EngineAudit::Process(audit) => {
let exits: Vec<_> = audit
.outputs
.iter()
.filter_map(|o| match o {
EngineOutput::PositionExit(p) => {
Some(EngineOutput::PositionExit(p.clone()))
}
_ => None,
})
.collect();
if exits.is_empty() {
NoneOneOrMany::None
} else if exits.len() == 1 {
NoneOneOrMany::One(exits.into_iter().next().unwrap())
} else {
NoneOneOrMany::Many(exits)
}
}
_ => NoneOneOrMany::None,
};
replica_manager.update_from_event(expiry_event, &outputs);
let replica_instrument = replica_manager
.replica_engine_state()
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(replica_instrument.position.positions.is_empty());
assert!(replica_instrument.orders.0.is_empty());
assert!(replica_instrument.expiration_processed);
}
fn build_put_option_engine(
trading_state: TradingState,
execution_tx: UnboundedTx<ExecutionRequest>,
) -> TestEngine {
let expiry = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let instruments = IndexedInstruments::builder()
.add_instrument(Instrument::new(
ExchangeId::BinanceSpot,
"binance_btc_put_50k",
"BTC-50000-P",
Underlying::new("btc", "usd"),
rustrade_instrument::instrument::quote::InstrumentQuoteAsset::UnderlyingQuote,
InstrumentKind::Option(OptionContract {
contract_size: dec!(1),
settlement_asset: "usd".into(),
kind: OptionKind::Put,
exercise: OptionExercise::European,
expiry,
strike: dec!(50_000),
}),
None,
))
.add_instrument(Instrument::spot(
ExchangeId::BinanceSpot,
"binance_spot_btc_usd",
"BTCUSD",
Underlying::new("btc", "usd"),
None,
))
.build();
let clock = HistoricalClock::new(STARTING_TIMESTAMP);
let state = EngineState::builder(&instruments, DefaultGlobalData, |_| {
DefaultInstrumentMarketData::default()
})
.time_engine_start(STARTING_TIMESTAMP)
.trading_state(trading_state)
.balances([(ExchangeId::BinanceSpot, "usd", STARTING_BALANCE_USDT)])
.build();
Engine::new(
clock,
state,
MultiExchangeTxMap::from_iter([(ExchangeId::BinanceSpot, Some(execution_tx))]),
TestBuyAndHoldStrategy { id: strategy_id() },
DefaultRiskManager::default(),
)
}
fn open_option_position_side(
engine: &mut TestEngine,
side: Side,
quantity: Decimal,
price: Decimal,
) {
let trade_id = match side {
Side::Buy => TradeId::new("opt-trade-open-buy"),
Side::Sell => TradeId::new("opt-trade-open-sell"),
};
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::Trade(Trade {
id: trade_id,
order_id: gen_order_id(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
time_exchange: time_plus_days(STARTING_TIMESTAMP, 1),
side,
price,
quantity,
fees: AssetFees::new(AssetIndex(1), Decimal::ZERO, Some(Decimal::ZERO)),
}),
}));
engine.process(event);
}
#[test]
fn test_contract_expiry_itm_put() {
let (execution_tx, _) = mpsc_unbounded();
let mut engine = build_put_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(45_000));
open_option_position(&mut engine, dec!(1), dec!(2_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited.len(), 1);
assert_eq!(exited[0].pnl_realised, dec!(3_000));
assert_eq!(exited[0].side, Side::Buy);
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.position
.positions
.is_empty()
);
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.expiration_processed
);
}
#[test]
fn test_contract_expiry_otm_put() {
let (execution_tx, _) = mpsc_unbounded();
let mut engine = build_put_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(55_000));
open_option_position(&mut engine, dec!(1), dec!(2_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited.len(), 1);
assert_eq!(exited[0].pnl_realised, dec!(-2_000));
}
#[test]
fn test_contract_expiry_short_call_itm() {
let (execution_tx, _) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(55_000));
open_option_position_side(&mut engine, Side::Sell, dec!(1), dec!(2_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited.len(), 1);
assert_eq!(exited[0].side, Side::Sell);
assert_eq!(exited[0].pnl_realised, dec!(-3_000));
}
#[test]
fn test_contract_expiry_short_call_otm() {
let (execution_tx, _) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(45_000));
open_option_position_side(&mut engine, Side::Sell, dec!(1), dec!(2_000));
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(exited.len(), 1);
assert_eq!(exited[0].side, Side::Sell);
assert_eq!(exited[0].pnl_realised, dec!(2_000));
}
type HedgingTestEngine = Engine<
HistoricalClock,
EngineState<DefaultGlobalData, DefaultInstrumentMarketData>,
MultiExchangeTxMap<UnboundedTx<ExecutionRequest>>,
TestBuyAndHoldStrategy,
DefaultRiskManager<EngineState<DefaultGlobalData, DefaultInstrumentMarketData>>,
>;
fn build_hedging_option_engine(
trading_state: TradingState,
execution_tx: UnboundedTx<ExecutionRequest>,
) -> HedgingTestEngine {
let expiry = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let instruments = IndexedInstruments::builder()
.add_instrument(Instrument::new(
ExchangeId::BinanceSpot,
"binance_btc_call_50k",
"BTC-50000-C",
Underlying::new("btc", "usd"),
rustrade_instrument::instrument::quote::InstrumentQuoteAsset::UnderlyingQuote,
InstrumentKind::Option(OptionContract {
contract_size: dec!(1),
settlement_asset: "usd".into(),
kind: OptionKind::Call,
exercise: OptionExercise::European,
expiry,
strike: dec!(50_000),
}),
None,
))
.add_instrument(Instrument::spot(
ExchangeId::BinanceSpot,
"binance_spot_btc_usd",
"BTCUSD",
Underlying::new("btc", "usd"),
None,
))
.build();
let clock = HistoricalClock::new(STARTING_TIMESTAMP);
let state = EngineState::builder(&instruments, DefaultGlobalData, |_| {
DefaultInstrumentMarketData::default()
})
.time_engine_start(STARTING_TIMESTAMP)
.trading_state(trading_state)
.oms_mode(OmsMode::Hedging)
.balances([(ExchangeId::BinanceSpot, "usd", STARTING_BALANCE_USDT)])
.build();
Engine::new(
clock,
state,
MultiExchangeTxMap::from_iter([(ExchangeId::BinanceSpot, Some(execution_tx))]),
TestBuyAndHoldStrategy { id: strategy_id() },
DefaultRiskManager::default(),
)
}
fn send_open_order_with_position_id(
engine: &mut HedgingTestEngine,
cid: ClientOrderId,
position_id: PositionId,
side: Side,
price: Decimal,
reduce_only: bool,
) {
let request = OrderRequestOpen {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid,
},
state: RequestOpen {
side,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
price: Some(price),
quantity: dec!(1),
position_id: Some(position_id),
reduce_only,
},
};
let event = EngineEvent::Command(Command::SendOpenRequests(OneOrMany::One(request)));
engine.process(event);
}
fn send_order_ack(
engine: &mut HedgingTestEngine,
cid: ClientOrderId,
exchange_order_id: OrderId,
side: Side,
) {
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::OrderSnapshot(Snapshot(Order {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid,
},
side,
price: Some(dec!(1_000)),
quantity: dec!(1),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: OrderState::active(Open {
id: exchange_order_id,
time_exchange: time_plus_days(STARTING_TIMESTAMP, 1),
filled_quantity: dec!(0),
}),
})),
}));
engine.process(event);
}
fn send_fill(
engine: &mut HedgingTestEngine,
exchange_order_id: OrderId,
side: Side,
price: Decimal,
) {
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::Trade(Trade {
id: TradeId::new(format!("fill-{}", exchange_order_id.0.as_str())),
order_id: exchange_order_id,
instrument: InstrumentIndex(0),
strategy: strategy_id(),
time_exchange: time_plus_days(STARTING_TIMESTAMP, 2),
side,
price,
quantity: dec!(1),
fees: AssetFees::new(AssetIndex(1), Decimal::ZERO, Some(Decimal::ZERO)),
}),
}));
engine.process(event);
}
fn send_fully_filled_snapshot(engine: &mut HedgingTestEngine, cid: ClientOrderId) {
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::OrderSnapshot(Snapshot(Order {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid,
},
side: Side::Buy, price: Some(dec!(0)),
quantity: dec!(1),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: OrderState::fully_filled(Filled::new(
OrderId::new("test_order"),
time_plus_days(STARTING_TIMESTAMP, 2),
dec!(1),
None,
)),
})),
}));
engine.process(event);
}
#[test]
fn test_hedging_fill_routing_to_correct_position_id() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
let cid_a = ClientOrderId::new("cid-a");
let pos_id_a = PositionId::new("leg-a");
let exchange_id_a = OrderId::new("exch-a");
send_open_order_with_position_id(
&mut engine,
cid_a.clone(),
pos_id_a.clone(),
Side::Buy,
dec!(1_000),
false,
);
assert!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.position_ids
.contains_key(&cid_a)
);
send_order_ack(&mut engine, cid_a.clone(), exchange_id_a.clone(), Side::Buy);
send_fill(&mut engine, exchange_id_a, Side::Buy, dec!(1_000));
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(
instr.position.positions.contains_key(&pos_id_a),
"position should exist under the caller-supplied PositionId"
);
assert_eq!(instr.position.positions.len(), 1);
}
#[test]
fn test_hedging_fill_routing_fallback_for_unknown_order() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
let unknown_order_id = OrderId::new("external-order-99");
send_fill(
&mut engine,
unknown_order_id.clone(),
Side::Buy,
dec!(1_000),
);
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
let expected_pos_id = PositionId::new(unknown_order_id.0.clone());
assert!(
instr.position.positions.contains_key(&expected_pos_id),
"fallback should open position under raw order ID"
);
}
#[test]
fn test_hedging_position_ids_cleanup_on_position_exit() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
let cid_a = ClientOrderId::new("cid-a");
let pos_id_a = PositionId::new("leg-a");
let exchange_id_a = OrderId::new("exch-a");
send_open_order_with_position_id(
&mut engine,
cid_a.clone(),
pos_id_a.clone(),
Side::Buy,
dec!(1_000),
false,
);
send_order_ack(&mut engine, cid_a.clone(), exchange_id_a.clone(), Side::Buy);
send_fill(&mut engine, exchange_id_a.clone(), Side::Buy, dec!(1_000));
send_fully_filled_snapshot(&mut engine, cid_a.clone());
let cid_b = ClientOrderId::new("cid-b");
let pos_id_b_same = pos_id_a.clone(); let exchange_id_b = OrderId::new("exch-b");
send_open_order_with_position_id(
&mut engine,
cid_b.clone(),
pos_id_b_same,
Side::Sell,
dec!(2_000),
true,
);
send_order_ack(
&mut engine,
cid_b.clone(),
exchange_id_b.clone(),
Side::Sell,
);
send_fill(&mut engine, exchange_id_b, Side::Sell, dec!(2_000));
send_fully_filled_snapshot(&mut engine, cid_b.clone());
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(
instr.position.positions.is_empty(),
"position should be closed"
);
assert!(
!instr.position_ids.values().any(|v| *v == pos_id_a),
"position_ids entries for closed position should be removed"
);
}
#[test]
fn test_contract_expiry_hedging_multi_position() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
send_spot_price(&mut engine, 1, dec!(55_000));
let cid_a = ClientOrderId::new("cid-a");
let pos_id_a = PositionId::new("leg-a");
let exchange_id_a = OrderId::new("exch-a");
let cid_b = ClientOrderId::new("cid-b");
let pos_id_b = PositionId::new("leg-b");
let exchange_id_b = OrderId::new("exch-b");
send_open_order_with_position_id(
&mut engine,
cid_a.clone(),
pos_id_a.clone(),
Side::Buy,
dec!(2_000),
false,
);
send_order_ack(&mut engine, cid_a, exchange_id_a.clone(), Side::Buy);
send_fill(&mut engine, exchange_id_a, Side::Buy, dec!(2_000));
send_open_order_with_position_id(
&mut engine,
cid_b.clone(),
pos_id_b.clone(),
Side::Buy,
dec!(3_000),
false,
);
send_order_ack(&mut engine, cid_b, exchange_id_b.clone(), Side::Buy);
send_fill(&mut engine, exchange_id_b, Side::Buy, dec!(3_000));
assert_eq!(
engine
.state
.instruments
.instrument_index(&InstrumentIndex(0))
.position
.positions
.len(),
2,
"two open positions before expiry"
);
let exited = engine.process_contract_expiry(&InstrumentIndex(0));
assert_eq!(
exited.len(),
2,
"both positions should be settled at expiry"
);
let mut pnls: Vec<Decimal> = exited.iter().map(|e| e.pnl_realised).collect();
pnls.sort();
assert_eq!(pnls, vec![dec!(2_000), dec!(3_000)]);
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(instr.position.positions.is_empty());
assert!(instr.expiration_processed);
assert!(instr.position_ids.is_empty());
}
#[test]
fn test_fee_model_per_contract_augments_trade_fees() {
let (execution_tx, _) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
engine
.state
.instruments
.instrument_index_mut(&InstrumentIndex(0))
.fee_model = FeeModelConfig::PerContract(PerContractFeeModel {
commission_per_contract: dec!(0.65),
});
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::Trade(Trade {
id: TradeId::new("fee-test-trade"),
order_id: gen_order_id(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
time_exchange: time_plus_days(STARTING_TIMESTAMP, 1),
side: Side::Buy,
price: dec!(1_000),
quantity: dec!(1),
fees: AssetFees::new(AssetIndex(1), Decimal::ZERO, Some(Decimal::ZERO)),
}),
}));
engine.process(event);
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
let pos = instr
.position
.positions
.get(&PositionId::NETTING) .expect("position should be open");
assert_eq!(pos.fees_enter.fees, dec!(0.65));
assert_eq!(pos.pnl_realised, dec!(-0.65));
}
#[test]
fn test_fee_model_zero_no_fees_on_trade() {
let (execution_tx, _) = mpsc_unbounded();
let mut engine = build_option_engine(TradingState::Disabled, execution_tx);
open_option_position(&mut engine, dec!(1), dec!(1_000));
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
let pos = instr
.position
.positions
.get(&PositionId::NETTING)
.expect("position should be open");
assert_eq!(pos.fees_enter.fees, Decimal::ZERO);
}
fn send_cancel_ack(engine: &mut HedgingTestEngine, cid: ClientOrderId, exchange_order_id: OrderId) {
let event = EngineEvent::Account(AccountStreamEvent::Item(AccountEvent {
exchange: ExchangeIndex(0),
kind: AccountEventKind::OrderCancelled(OrderResponseCancel {
key: OrderKey {
exchange: ExchangeIndex(0),
instrument: InstrumentIndex(0),
strategy: strategy_id(),
cid,
},
state: Ok(Cancelled {
id: exchange_order_id,
time_exchange: time_plus_days(STARTING_TIMESTAMP, 1),
filled_quantity: dec!(0),
}),
}),
}));
engine.process(event);
}
#[test]
fn test_hedging_pending_fill_replayed_on_ack() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
let cid = ClientOrderId::new("cid-pending");
let pos_id = PositionId::new("leg-pending");
let exchange_id = OrderId::new("exch-pending");
send_open_order_with_position_id(
&mut engine,
cid.clone(),
pos_id.clone(),
Side::Buy,
dec!(1_000),
false,
);
send_fill(&mut engine, exchange_id.clone(), Side::Buy, dec!(1_000));
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(
instr.position.positions.is_empty(),
"position should NOT be created yet — fill is pending"
);
assert_eq!(
instr.pending_fills.len(),
1,
"fill should be buffered in pending_fills"
);
send_order_ack(&mut engine, cid.clone(), exchange_id.clone(), Side::Buy);
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(
instr.position.positions.contains_key(&pos_id),
"position should exist under caller-supplied PositionId after ack"
);
assert!(
instr.pending_fills.is_empty(),
"pending_fills should be drained after replay"
);
}
#[test]
fn test_hedging_pending_fill_drained_on_cancel_ack() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
let cid = ClientOrderId::new("cid-cancel-race");
let pos_id = PositionId::new("leg-cancel-race");
let exchange_id = OrderId::new("exch-cancel-race");
send_open_order_with_position_id(
&mut engine,
cid.clone(),
pos_id.clone(),
Side::Buy,
dec!(1_000),
false,
);
send_fill(&mut engine, exchange_id.clone(), Side::Buy, dec!(1_000));
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert_eq!(instr.pending_fills.len(), 1, "fill should be buffered");
send_cancel_ack(&mut engine, cid.clone(), exchange_id.clone());
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(
instr.pending_fills.is_empty(),
"pending_fills should be cleared when no OpenInFlight orders remain"
);
}
#[test]
fn test_contract_expiry_clears_pending_fills() {
let (execution_tx, _execution_rx) = mpsc_unbounded();
let mut engine = build_hedging_option_engine(TradingState::Disabled, execution_tx);
let cid = ClientOrderId::new("cid-expiry-pending");
let pos_id = PositionId::new("leg-expiry-pending");
let exchange_id = OrderId::new("exch-expiry-pending");
send_open_order_with_position_id(
&mut engine,
cid.clone(),
pos_id.clone(),
Side::Buy,
dec!(1_000),
false,
);
send_fill(&mut engine, exchange_id.clone(), Side::Buy, dec!(1_000));
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert_eq!(instr.pending_fills.len(), 1, "setup: pending fill exists");
send_spot_price(&mut engine, 1, dec!(55_000));
let expiry_event = EngineEvent::ContractExpiry(InstrumentIndex(0));
engine.process(expiry_event);
let instr = engine
.state
.instruments
.instrument_index(&InstrumentIndex(0));
assert!(
instr.expiration_processed,
"expiry should be processed — if this fails, the spot price lookup failed"
);
assert!(
instr.pending_fills.is_empty(),
"pending_fills must be cleared during contract expiry"
);
}