use super::reject::RejectReason;
use crate::book::common::BatchProcessState;
use crate::types::*;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
Hash,
strum::Display,
strum::EnumString,
rkyv::Archive,
rkyv::Serialize,
rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum Side {
Yes,
No,
}
#[allow(non_upper_case_globals)]
impl Side {
pub const Back: Self = Self::Yes;
pub const Lay: Self = Self::No;
pub const Buy: Self = Self::Yes;
pub const Sell: Self = Self::No;
}
pub type BinarySide = Side;
pub type BinaryTimeInForce = TimeInForce;
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
Hash,
strum::Display,
strum::EnumString,
rkyv::Archive,
rkyv::Serialize,
rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum Persistence {
Lapse,
Persist,
MarketOnClose,
}
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
Hash,
rkyv::Archive,
rkyv::Serialize,
rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TimeInForce {
Gtc,
ImmediateOrCancel,
FillOrKill { min_fill: Option<Quantity> },
}
impl TimeInForce {
pub(crate) fn required_fok_fill(
self,
order_qty: Quantity,
) -> Result<Option<Quantity>, RejectReason> {
let Self::FillOrKill { min_fill } = self else {
return Ok(None);
};
let required = match min_fill {
Some(qty) if qty.0 == 0 || qty.0 > order_qty.0 => {
return Err(RejectReason::InvalidTimeInForce);
}
Some(qty) => qty,
None => order_qty,
};
Ok(Some(required))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ReduceOrderTarget {
TotalStake(Money),
RemainingStake(Money),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ReduceOrderCondition {
#[serde(default)]
pub expected_odds: Option<OddsX10000>,
#[serde(default)]
pub expected_stake: Option<Money>,
#[serde(default)]
pub expected_matched_stake: Option<Money>,
#[serde(default)]
pub expected_remaining_stake: Option<Money>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ReduceBinaryOrderTarget {
TotalShares(u64),
RemainingShares(u64),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ReduceBinaryOrderCondition {
#[serde(default)]
pub expected_price_ticks: Option<u16>,
#[serde(default)]
pub expected_qty_shares: Option<u64>,
#[serde(default)]
pub expected_filled_shares: Option<u64>,
#[serde(default)]
pub expected_remaining_shares: Option<u64>,
}
#[derive(
Debug,
Clone,
Copy,
Default,
Serialize,
Deserialize,
PartialEq,
Eq,
Hash,
strum::Display,
strum::EnumString,
rkyv::Archive,
rkyv::Serialize,
rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum MarketState {
#[default]
Open,
Suspended,
Closed,
Deactivated,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Command {
#[serde(default)]
pub correlation_id: Option<CorrelationId>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
pub market_id: MarketId,
pub kind: CommandKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct RunnerChange {
pub runner_id: RunnerId,
pub runner_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CommandKind {
CreateMarket {
name: String,
market_model: MarketModel,
book_type: Option<BookType>,
market_kind: MarketKind,
#[serde(default)]
market_state: MarketState,
#[serde(default)]
market_phase: MarketPhase,
runner_ids: Vec<RunnerId>,
runner_labels: Vec<String>,
},
AddRunners {
runner_ids: Vec<RunnerId>,
runner_labels: Vec<String>,
},
ChangeRunners {
add: Vec<RunnerChange>,
remove: Vec<RunnerChange>,
},
PlaceOrder {
runner_id: RunnerId,
account_id: AccountId,
client_order_id: Option<ClientOrderId>,
side: Side,
odds: OddsX10000,
stake: Money,
persistence: Persistence,
time_in_force: TimeInForce,
},
PlaceBinaryOrder {
account_id: AccountId,
client_order_id: Option<ClientOrderId>,
side: Side,
price_ticks: u16,
qty_shares: u64,
time_in_force: TimeInForce,
},
CancelOrder {
account_id: AccountId,
order_id: OrderId,
},
ReduceOrder {
account_id: AccountId,
order_id: OrderId,
new_odds: Option<OddsX10000>,
#[serde(default)]
target: Option<ReduceOrderTarget>,
#[serde(default)]
condition: Option<ReduceOrderCondition>,
},
ReduceBinaryOrder {
account_id: AccountId,
order_id: OrderId,
new_price_ticks: Option<u16>,
#[serde(default)]
target: Option<ReduceBinaryOrderTarget>,
#[serde(default)]
condition: Option<ReduceBinaryOrderCondition>,
},
SetMarketState { state: MarketState },
AwaitLiveMarket,
ReturnToPreMarket {
#[serde(default)]
reason: String,
},
GoLiveMarket,
CloseMarket {
#[serde(default)]
reason: String,
},
ContinueBatchProcess,
BatchCancelOrders {
from_created_at_inclusive: Option<DateTime>,
to_created_at_inclusive: Option<DateTime>,
account_id: Option<AccountId>,
runner_id: Option<RunnerId>,
reason: String,
},
RemoveRunner {
runner_id: RunnerId,
reduction_factor_bps: Option<u32>,
},
RemoveRunners {
runner_ids: Vec<RunnerId>,
reduction_factor_bps: Option<u32>,
},
VoidTrades {
timestamp: DateTime,
start_time: DateTime,
end_time: DateTime,
void_reason: String,
},
HaltMarket { reason: String },
ResumeMarket,
}
impl Command {
pub fn market_id(&self) -> MarketId {
self.market_id
}
}
impl CommandKind {
#[inline]
pub fn is_internal_batch_continue(&self) -> bool {
matches!(self, Self::ContinueBatchProcess)
}
#[inline]
pub fn is_batch_interleavable_state_admin_command(&self) -> bool {
matches!(
self,
Self::SetMarketState { .. }
| Self::AwaitLiveMarket
| Self::ReturnToPreMarket { .. }
| Self::GoLiveMarket
| Self::HaltMarket { .. }
| Self::ResumeMarket
)
}
#[inline]
pub fn is_allowed_while_halted(&self, has_active_batch: bool) -> bool {
if has_active_batch {
self.is_internal_batch_continue()
|| self.is_batch_interleavable_state_admin_command()
|| matches!(self, Self::CloseMarket { .. })
} else {
matches!(
self,
Self::AwaitLiveMarket
| Self::ReturnToPreMarket { .. }
| Self::GoLiveMarket
| Self::AddRunners { .. }
| Self::ChangeRunners { .. }
| Self::RemoveRunner { .. }
| Self::RemoveRunners { .. }
| Self::BatchCancelOrders { .. }
| Self::ContinueBatchProcess
)
}
}
#[inline]
pub fn validate_book_gate(
&self,
batch_state: Option<&BatchProcessState>,
is_halted: bool,
) -> Result<(), RejectReason> {
let is_internal_batch_continue = self.is_internal_batch_continue();
let is_batch_interleavable_state_admin = self.is_batch_interleavable_state_admin_command();
let is_close_market = matches!(self, Self::CloseMarket { .. });
if let Some(batch_state) = batch_state {
let allow_close_during_non_close_batch = is_close_market && !batch_state.is_close();
if !is_internal_batch_continue
&& !is_batch_interleavable_state_admin
&& !allow_close_during_non_close_batch
{
return Err(RejectReason::MarketBatchCancelling);
}
}
if is_halted && !self.is_allowed_while_halted(batch_state.is_some()) {
return Err(RejectReason::MarketHalted);
}
Ok(())
}
#[inline]
pub fn may_affect_batch_scheduler(&self) -> bool {
matches!(
self,
Self::SetMarketState {
state: MarketState::Closed | MarketState::Suspended | MarketState::Deactivated
} | Self::AwaitLiveMarket
| Self::GoLiveMarket
| Self::CloseMarket { .. }
| Self::ContinueBatchProcess
| Self::BatchCancelOrders { .. }
| Self::ChangeRunners { .. }
| Self::RemoveRunner { .. }
| Self::RemoveRunners { .. }
)
}
}
impl fmt::Display for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.kind)
}
}
impl fmt::Display for CommandKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
CommandKind::CreateMarket { .. } => "CREATE_MARKET",
CommandKind::AddRunners { .. } => "ADD_RUNNERS",
CommandKind::ChangeRunners { .. } => "CHANGE_RUNNERS",
CommandKind::PlaceOrder { .. } => "PLACE_ORDER",
CommandKind::PlaceBinaryOrder { .. } => "PLACE_BINARY_ORDER",
CommandKind::CancelOrder { .. } => "CANCEL_ORDER",
CommandKind::ReduceOrder { .. } => "REDUCE_ORDER",
CommandKind::ReduceBinaryOrder { .. } => "REDUCE_BINARY_ORDER",
CommandKind::SetMarketState { .. } => "SET_MARKET_STATE",
CommandKind::AwaitLiveMarket => "AWAIT_LIVE_MARKET",
CommandKind::ReturnToPreMarket { .. } => "RETURN_TO_PRE_MARKET",
CommandKind::GoLiveMarket => "GO_LIVE_MARKET",
CommandKind::CloseMarket { .. } => "CLOSE_MARKET",
CommandKind::ContinueBatchProcess => "CONTINUE_BATCH_PROCESS",
CommandKind::BatchCancelOrders { .. } => "BATCH_CANCEL_ORDERS",
CommandKind::RemoveRunner { .. } => "REMOVE_RUNNER",
CommandKind::RemoveRunners { .. } => "REMOVE_RUNNERS",
CommandKind::VoidTrades { .. } => "VOID_TRADES",
CommandKind::HaltMarket { .. } => "HALT_MARKET",
CommandKind::ResumeMarket => "RESUME_MARKET",
};
write!(f, "{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::book::{BatchMode, BatchProcessState};
#[test]
fn may_affect_batch_scheduler_flags_only_batch_relevant_commands() {
assert!(
CommandKind::CloseMarket {
reason: "x".to_string(),
}
.may_affect_batch_scheduler()
);
assert!(CommandKind::ContinueBatchProcess.may_affect_batch_scheduler());
assert!(
CommandKind::BatchCancelOrders {
from_created_at_inclusive: None,
to_created_at_inclusive: None,
account_id: None,
runner_id: None,
reason: "x".to_string(),
}
.may_affect_batch_scheduler()
);
assert!(
CommandKind::RemoveRunner {
runner_id: RunnerId(1),
reduction_factor_bps: None,
}
.may_affect_batch_scheduler()
);
assert!(
CommandKind::RemoveRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
reduction_factor_bps: None,
}
.may_affect_batch_scheduler()
);
assert!(
CommandKind::ChangeRunners {
add: vec![RunnerChange {
runner_id: RunnerId(3),
runner_label: "C".to_string(),
}],
remove: vec![RunnerChange {
runner_id: RunnerId(1),
runner_label: "A".to_string(),
}],
}
.may_affect_batch_scheduler()
);
assert!(CommandKind::AwaitLiveMarket.may_affect_batch_scheduler());
assert!(CommandKind::GoLiveMarket.may_affect_batch_scheduler());
assert!(
CommandKind::SetMarketState {
state: MarketState::Closed,
}
.may_affect_batch_scheduler()
);
assert!(
CommandKind::SetMarketState {
state: MarketState::Suspended,
}
.may_affect_batch_scheduler()
);
assert!(
CommandKind::SetMarketState {
state: MarketState::Deactivated,
}
.may_affect_batch_scheduler()
);
assert!(
!CommandKind::ReturnToPreMarket {
reason: "x".to_string(),
}
.may_affect_batch_scheduler()
);
assert!(
!CommandKind::SetMarketState {
state: MarketState::Open,
}
.may_affect_batch_scheduler()
);
assert!(
!CommandKind::AddRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
runner_labels: vec!["A".to_string(), "B".to_string()],
}
.may_affect_batch_scheduler()
);
assert!(
!CommandKind::PlaceOrder {
runner_id: RunnerId(1),
account_id: AccountId::from(1_u64),
client_order_id: None,
side: Side::Yes,
odds: OddsX10000(20_000),
stake: Money(100),
persistence: Persistence::Persist,
time_in_force: TimeInForce::Gtc,
}
.may_affect_batch_scheduler()
);
}
#[test]
fn batch_interleavable_state_admin_helper_is_whitelist_only() {
assert!(
CommandKind::SetMarketState {
state: MarketState::Open,
}
.is_batch_interleavable_state_admin_command()
);
assert!(CommandKind::AwaitLiveMarket.is_batch_interleavable_state_admin_command());
assert!(
CommandKind::ReturnToPreMarket {
reason: "x".to_string(),
}
.is_batch_interleavable_state_admin_command()
);
assert!(CommandKind::GoLiveMarket.is_batch_interleavable_state_admin_command());
assert!(
CommandKind::HaltMarket {
reason: "x".to_string(),
}
.is_batch_interleavable_state_admin_command()
);
assert!(CommandKind::ResumeMarket.is_batch_interleavable_state_admin_command());
assert!(
!CommandKind::CloseMarket {
reason: "x".to_string(),
}
.is_batch_interleavable_state_admin_command()
);
assert!(!CommandKind::ContinueBatchProcess.is_batch_interleavable_state_admin_command());
assert!(
!CommandKind::BatchCancelOrders {
from_created_at_inclusive: None,
to_created_at_inclusive: None,
account_id: None,
runner_id: None,
reason: "x".to_string(),
}
.is_batch_interleavable_state_admin_command()
);
assert!(
!CommandKind::RemoveRunner {
runner_id: RunnerId(1),
reduction_factor_bps: None,
}
.is_batch_interleavable_state_admin_command()
);
assert!(
!CommandKind::RemoveRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
reduction_factor_bps: None,
}
.is_batch_interleavable_state_admin_command()
);
assert!(
!CommandKind::AddRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
runner_labels: vec!["A".to_string(), "B".to_string()],
}
.is_batch_interleavable_state_admin_command()
);
assert!(
!CommandKind::ChangeRunners {
add: vec![RunnerChange {
runner_id: RunnerId(3),
runner_label: "C".to_string(),
}],
remove: vec![],
}
.is_batch_interleavable_state_admin_command()
);
assert!(
!CommandKind::PlaceOrder {
runner_id: RunnerId(1),
account_id: AccountId::from(1_u64),
client_order_id: None,
side: Side::Yes,
odds: OddsX10000(20_000),
stake: Money(100),
persistence: Persistence::Persist,
time_in_force: TimeInForce::Gtc,
}
.is_batch_interleavable_state_admin_command()
);
}
#[test]
fn allowed_while_halted_helper_matches_batch_mode_rules() {
assert!(CommandKind::ContinueBatchProcess.is_allowed_while_halted(true));
assert!(
CommandKind::CloseMarket {
reason: "x".to_string(),
}
.is_allowed_while_halted(true)
);
assert!(
CommandKind::BatchCancelOrders {
from_created_at_inclusive: None,
to_created_at_inclusive: None,
account_id: None,
runner_id: None,
reason: "x".to_string(),
}
.is_allowed_while_halted(false)
);
assert!(CommandKind::ContinueBatchProcess.is_allowed_while_halted(false));
assert!(CommandKind::AwaitLiveMarket.is_allowed_while_halted(false));
assert!(
CommandKind::ReturnToPreMarket {
reason: "x".to_string(),
}
.is_allowed_while_halted(false)
);
assert!(CommandKind::GoLiveMarket.is_allowed_while_halted(false));
assert!(
CommandKind::AddRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
runner_labels: vec!["A".to_string(), "B".to_string()],
}
.is_allowed_while_halted(false)
);
assert!(
CommandKind::RemoveRunner {
runner_id: RunnerId(1),
reduction_factor_bps: None,
}
.is_allowed_while_halted(false)
);
assert!(
CommandKind::RemoveRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
reduction_factor_bps: None,
}
.is_allowed_while_halted(false)
);
assert!(
CommandKind::ChangeRunners {
add: vec![RunnerChange {
runner_id: RunnerId(3),
runner_label: "C".to_string(),
}],
remove: vec![],
}
.is_allowed_while_halted(false)
);
assert!(
!CommandKind::RemoveRunner {
runner_id: RunnerId(1),
reduction_factor_bps: None,
}
.is_allowed_while_halted(true)
);
assert!(
!CommandKind::RemoveRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
reduction_factor_bps: None,
}
.is_allowed_while_halted(true)
);
assert!(
!CommandKind::ChangeRunners {
add: vec![RunnerChange {
runner_id: RunnerId(3),
runner_label: "C".to_string(),
}],
remove: vec![],
}
.is_allowed_while_halted(true)
);
assert!(
!CommandKind::SetMarketState {
state: MarketState::Deactivated,
}
.is_allowed_while_halted(false)
);
assert!(
!CommandKind::CloseMarket {
reason: "x".to_string(),
}
.is_allowed_while_halted(false)
);
assert!(
!CommandKind::PlaceOrder {
runner_id: RunnerId(1),
account_id: AccountId::from(1_u64),
client_order_id: None,
side: Side::Yes,
odds: OddsX10000(20_000),
stake: Money(100),
persistence: Persistence::Persist,
time_in_force: TimeInForce::Gtc,
}
.is_allowed_while_halted(true)
);
}
#[test]
fn validate_book_gate_reuses_shared_batch_and_halt_rules() {
let close_batch = BatchProcessState::close(5, 2);
let lapse_batch = BatchProcessState::lapse(5, BatchMode::InPlayLapse);
assert_eq!(
CommandKind::CloseMarket {
reason: "x".to_string(),
}
.validate_book_gate(Some(&close_batch), false),
Err(RejectReason::MarketBatchCancelling)
);
assert_eq!(
CommandKind::CloseMarket {
reason: "x".to_string(),
}
.validate_book_gate(Some(&lapse_batch), true),
Ok(())
);
assert_eq!(
CommandKind::PlaceOrder {
runner_id: RunnerId(1),
account_id: AccountId::from(1_u64),
client_order_id: None,
side: Side::Yes,
odds: OddsX10000(20_000),
stake: Money(100),
persistence: Persistence::Persist,
time_in_force: TimeInForce::Gtc,
}
.validate_book_gate(Some(&lapse_batch), false),
Err(RejectReason::MarketBatchCancelling)
);
assert_eq!(
CommandKind::BatchCancelOrders {
from_created_at_inclusive: None,
to_created_at_inclusive: None,
account_id: None,
runner_id: None,
reason: "x".to_string(),
}
.validate_book_gate(None, true),
Ok(())
);
assert_eq!(
CommandKind::AddRunners {
runner_ids: vec![RunnerId(1), RunnerId(2)],
runner_labels: vec!["A".to_string(), "B".to_string()],
}
.validate_book_gate(Some(&lapse_batch), false),
Err(RejectReason::MarketBatchCancelling)
);
assert_eq!(
CommandKind::ChangeRunners {
add: vec![RunnerChange {
runner_id: RunnerId(3),
runner_label: "C".to_string(),
}],
remove: vec![],
}
.validate_book_gate(Some(&lapse_batch), false),
Err(RejectReason::MarketBatchCancelling)
);
}
#[test]
fn change_runners_display_name_is_wire_command_name() {
let kind = CommandKind::ChangeRunners {
add: vec![RunnerChange {
runner_id: RunnerId(3),
runner_label: "C".to_string(),
}],
remove: vec![],
};
assert_eq!(kind.to_string(), "CHANGE_RUNNERS");
}
}