betex 0.35.0

Betfair / Prediction Market Exchange
Documentation
use betex::{
    book::close_process::DEFAULT_CLOSE_BATCH_EVENTS,
    book::protocol::command::{Command, CommandKind, MarketState, Persistence, Side, TimeInForce},
    book::{BatchMode, Book, BookEvent, BookMarketState, CancelCause},
    types::{AccountId, CorrelationId, MarketId, MarketPhase, Money, OddsX10000, RunnerId},
};

fn exec(book: &mut Book, cmd: Command) -> Vec<betex::book::BookEventEnvelope> {
    let (events, _) = book.handle(&cmd).expect("command should succeed");
    book.apply_all_events(&events);
    events
}

fn place_order_cmd(
    market_id: MarketId,
    correlation_id: u64,
    account_id: u64,
    runner_id: u32,
    persistence: Persistence,
) -> Command {
    Command {
        correlation_id: Some(CorrelationId(correlation_id.to_string())),
        metadata: None,
        market_id,
        kind: CommandKind::PlaceOrder {
            runner_id: RunnerId(runner_id),
            account_id: AccountId::from(account_id),
            client_order_id: None,
            side: Side::Yes,
            odds: OddsX10000(20000),
            stake: Money(100),
            persistence,
            time_in_force: TimeInForce::Gtc,
        },
    }
}

fn close_cmd(market_id: MarketId) -> Command {
    Command {
        correlation_id: Some(CorrelationId(1001.to_string())),
        metadata: None,
        market_id,
        kind: CommandKind::CloseMarket {
            reason: "TEST_CLOSE".to_string(),
        },
    }
}

fn suspend_cmd(market_id: MarketId) -> Command {
    Command {
        correlation_id: Some(CorrelationId(2001.to_string())),
        metadata: None,
        market_id,
        kind: CommandKind::SetMarketState {
            state: MarketState::Suspended,
        },
    }
}

fn go_live_cmd(market_id: MarketId) -> Command {
    Command {
        correlation_id: Some(CorrelationId(2003.to_string())),
        metadata: None,
        market_id,
        kind: CommandKind::GoLiveMarket,
    }
}

fn continue_lapse_cmd(market_id: MarketId) -> Command {
    Command {
        correlation_id: Some(CorrelationId(2002.to_string())),
        metadata: None,
        market_id,
        kind: CommandKind::ContinueBatchProcess,
    }
}

fn batch_cancel_cmd(
    market_id: MarketId,
    reason: &str,
    metadata: Option<serde_json::Value>,
) -> Command {
    Command {
        correlation_id: Some(CorrelationId(4001.to_string())),
        metadata,
        market_id,
        kind: CommandKind::BatchCancelOrders {
            from_created_at_inclusive: None,
            to_created_at_inclusive: None,
            account_id: None,
            runner_id: None,
            reason: reason.to_string(),
        },
    }
}

fn continue_batch_cancel_cmd(market_id: MarketId) -> Command {
    Command {
        correlation_id: Some(CorrelationId(4002.to_string())),
        metadata: None,
        market_id,
        kind: CommandKind::ContinueBatchProcess,
    }
}

#[test]
fn completion_event_emitted_for_each_batch_cause_even_without_orders() {
    let mid_close = MarketId(301);
    let mut close_book = Book::new_multi_runner(mid_close, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    close_book.set_close_batch_max_events(2);
    let close_events = exec(&mut close_book, close_cmd(mid_close));
    assert!(close_events.iter().any(|e| {
        matches!(
            e.event,
            BookEvent::BatchProcessCompleted {
                batch_mode: BatchMode::Close
            }
        )
    }));

    let mid_batch = MarketId(302);
    let mut batch_book = Book::new_multi_runner(mid_batch, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    batch_book.set_close_batch_max_events(10);
    let batch_events = exec(
        &mut batch_book,
        batch_cancel_cmd(mid_batch, "EMPTY_BATCH", None),
    );
    assert!(batch_events.iter().any(|e| {
        matches!(
            e.event,
            BookEvent::BatchProcessCompleted {
                batch_mode: BatchMode::FilteredCancel
            }
        )
    }));

    let mid_suspend = MarketId(303);
    let mut suspend_book =
        Book::new_multi_runner(mid_suspend, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let suspend_events = exec(&mut suspend_book, suspend_cmd(mid_suspend));
    assert!(suspend_events.iter().any(|e| {
        matches!(
            e.event,
            BookEvent::BatchProcessCompleted {
                batch_mode: BatchMode::SuspendLapse
            }
        )
    }));
    assert_eq!(suspend_book.market_state(), BookMarketState::Suspended);

    let mid_in_play = MarketId(304);
    let mut in_play_book =
        Book::new_multi_runner(mid_in_play, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let in_play_events = exec(&mut in_play_book, go_live_cmd(mid_in_play));
    assert!(in_play_events.iter().any(|e| {
        matches!(
            e.event,
            BookEvent::BatchProcessCompleted {
                batch_mode: BatchMode::InPlayLapse
            }
        )
    }));
    assert_eq!(in_play_book.market_state(), BookMarketState::Open);
    assert_eq!(in_play_book.market_phase(), MarketPhase::Live);
}

#[test]
fn completion_event_emitted_once_for_multi_batch_lapse() {
    let mid_suspend = MarketId(305);
    let mut suspend_book =
        Book::new_multi_runner(mid_suspend, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let total = DEFAULT_CLOSE_BATCH_EVENTS as u64 + 3;
    for i in 0..total {
        let _ = exec(
            &mut suspend_book,
            place_order_cmd(mid_suspend, i + 1, 50_000 + i, 1, Persistence::Lapse),
        );
    }

    let mut suspend_all = Vec::new();
    suspend_all.extend(exec(&mut suspend_book, suspend_cmd(mid_suspend)));
    while suspend_book.batch_process_state().is_some() {
        suspend_all.extend(exec(&mut suspend_book, continue_lapse_cmd(mid_suspend)));
    }
    let suspend_completed = suspend_all
        .iter()
        .filter(|e| {
            matches!(
                e.event,
                BookEvent::BatchProcessCompleted {
                    batch_mode: BatchMode::SuspendLapse
                }
            )
        })
        .count();
    assert_eq!(suspend_completed, 1);

    let mid_in_play = MarketId(306);
    let mut in_play_book =
        Book::new_multi_runner(mid_in_play, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    for i in 0..total {
        let _ = exec(
            &mut in_play_book,
            place_order_cmd(mid_in_play, i + 1, 55_000 + i, 1, Persistence::Lapse),
        );
    }

    let mut in_play_all = Vec::new();
    in_play_all.extend(exec(&mut in_play_book, go_live_cmd(mid_in_play)));
    while in_play_book.batch_process_state().is_some() {
        in_play_all.extend(exec(&mut in_play_book, continue_lapse_cmd(mid_in_play)));
    }
    let in_play_completed = in_play_all
        .iter()
        .filter(|e| {
            matches!(
                e.event,
                BookEvent::BatchProcessCompleted {
                    batch_mode: BatchMode::InPlayLapse
                }
            )
        })
        .count();
    assert_eq!(in_play_completed, 1);
}

#[test]
fn batch_cancel_recovery_does_not_reapply_initial_metadata() {
    let market_id = MarketId(308);
    let mut book = Book::new_multi_runner(market_id, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    for i in 0..3 {
        let _ = exec(
            &mut book,
            place_order_cmd(market_id, i + 1, 70_000 + i, 1, Persistence::Persist),
        );
    }

    let metadata = serde_json::json!({"trace":"recovery-meta"});
    book.set_close_batch_max_events(2);
    let start = exec(
        &mut book,
        batch_cancel_cmd(market_id, "RECOVERY_BATCH", Some(metadata.clone())),
    );
    assert!(matches!(
        start.first().map(|e| &e.event),
        Some(BookEvent::BatchProcessStarted { .. })
    ));
    assert_eq!(
        start.first().and_then(|e| e.metadata.clone()),
        Some(metadata)
    );

    let mut restarted = book.clone();
    while restarted.batch_process_state().is_some() {
        let events = exec(&mut restarted, continue_batch_cancel_cmd(market_id));
        assert!(events.iter().all(|env| env.metadata.is_none()));
    }
}

#[test]
fn cancel_cause_and_detail_distinguish_sources() {
    let mid_user = MarketId(308);
    let mut user_book = Book::new_multi_runner(mid_user, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let placed = exec(
        &mut user_book,
        place_order_cmd(mid_user, 1, 80_000, 1, Persistence::Persist),
    );
    let order_id = match placed.first().map(|e| &e.event) {
        Some(BookEvent::OrderAccepted { order_id, .. }) => *order_id,
        _ => panic!("expected OrderAccepted"),
    };
    let user_cancel = exec(
        &mut user_book,
        Command {
            correlation_id: Some(CorrelationId(2.to_string())),
            metadata: None,
            market_id: mid_user,
            kind: CommandKind::CancelOrder {
                account_id: AccountId::from(80_000),
                order_id,
            },
        },
    );
    assert!(user_cancel.iter().any(|e| {
        matches!(
            &e.event,
            BookEvent::OrderCancelled {
                cancel_cause: CancelCause::UserCancel,
                cause_detail: Some(detail),
                ..
            } if detail == "USER_CANCEL"
        )
    }));

    let mid_batch = MarketId(309);
    let mut batch_book = Book::new_multi_runner(mid_batch, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let _ = exec(
        &mut batch_book,
        place_order_cmd(mid_batch, 1, 81_000, 1, Persistence::Persist),
    );
    batch_book.set_close_batch_max_events(10);
    let batch_cancel = exec(
        &mut batch_book,
        batch_cancel_cmd(mid_batch, "CAUSE_BATCH", None),
    );
    assert!(batch_cancel.iter().any(|e| {
        matches!(
            &e.event,
            BookEvent::OrderCancelledBatched {
                batch_mode: BatchMode::FilteredCancel,
                detail: Some(detail),
                ..
            } if detail == "CAUSE_BATCH"
        )
    }));

    let mid_close = MarketId(310);
    let mut close_book = Book::new_multi_runner(mid_close, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let _ = exec(
        &mut close_book,
        place_order_cmd(mid_close, 1, 82_000, 1, Persistence::Persist),
    );
    close_book.set_close_batch_max_events(10);
    let close_events = exec(&mut close_book, close_cmd(mid_close));
    assert!(close_events.iter().any(|e| {
        matches!(
            &e.event,
            BookEvent::OrderCancelledBatched {
                batch_mode: BatchMode::Close,
                detail: None,
                ..
            }
        )
    }));

    let mid_suspend = MarketId(311);
    let mut suspend_book =
        Book::new_multi_runner(mid_suspend, [RunnerId(1), RunnerId(2), RunnerId(3)]);
    let _ = exec(
        &mut suspend_book,
        place_order_cmd(mid_suspend, 1, 83_000, 1, Persistence::Lapse),
    );
    let suspend_events = exec(&mut suspend_book, suspend_cmd(mid_suspend));
    assert!(suspend_events.iter().any(|e| {
        matches!(
            &e.event,
            BookEvent::OrderCancelledBatched {
                batch_mode: BatchMode::SuspendLapse,
                detail: None,
                ..
            }
        )
    }));
}