use crate::orderbook::error::OrderBookError;
use crossbeam::atomic::AtomicCell;
use dashmap::DashMap;
use pricelevel::{Hash32, Id, PriceLevelSnapshot};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tracing::warn;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReferencePriceSource {
LastTrade,
Mid,
FixedPrice(u128),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RiskConfig {
pub max_open_orders_per_account: Option<u64>,
pub max_notional_per_account: Option<u128>,
pub price_band_bps: Option<u32>,
pub reference_price: Option<ReferencePriceSource>,
}
impl RiskConfig {
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[inline]
#[must_use]
pub fn with_max_open_orders_per_account(mut self, n: u64) -> Self {
self.max_open_orders_per_account = Some(n);
self
}
#[inline]
#[must_use]
pub fn with_max_notional_per_account(mut self, n: u128) -> Self {
self.max_notional_per_account = Some(n);
self
}
#[inline]
#[must_use]
pub fn with_price_band_bps(mut self, bps: u32, source: ReferencePriceSource) -> Self {
self.price_band_bps = Some(bps);
self.reference_price = Some(source);
self
}
}
#[derive(Debug, Default)]
pub struct RiskCounters {
pub(super) open_count: AtomicU64,
pub(super) resting_notional: AtomicCell<u128>,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct RiskEntry {
pub(super) account: Hash32,
pub(super) price: u128,
pub(super) remaining_qty: u64,
}
#[derive(Debug, Default)]
pub struct RiskState {
pub(super) config: Option<RiskConfig>,
pub(super) counters: DashMap<Hash32, RiskCounters>,
pub(super) orders: DashMap<Id, RiskEntry>,
pub(super) warned_no_reference: AtomicBool,
}
#[inline]
fn saturating_sub_u64(counter: &AtomicU64, delta: u64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(delta))
});
}
#[inline]
fn saturating_sub_u128(cell: &AtomicCell<u128>, delta: u128) {
let mut current = cell.load();
loop {
let new = current.saturating_sub(delta);
match cell.compare_exchange(current, new) {
Ok(_) => return,
Err(actual) => current = actual,
}
}
}
impl RiskState {
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_config(&mut self, cfg: RiskConfig) {
self.config = Some(cfg);
self.warned_no_reference.store(false, Ordering::Relaxed);
}
#[inline]
#[must_use]
pub fn config(&self) -> Option<&RiskConfig> {
self.config.as_ref()
}
pub fn disable(&mut self) {
self.config = None;
}
#[inline]
pub(super) fn check_limit_admission(
&self,
account: Hash32,
price: u128,
quantity: u64,
reference_price: Option<u128>,
) -> Result<(), OrderBookError> {
let Some(cfg) = self.config.as_ref() else {
return Ok(());
};
if let Some(limit) = cfg.max_open_orders_per_account {
let current = self
.counters
.get(&account)
.map(|c| c.open_count.load(Ordering::Relaxed))
.unwrap_or(0);
if current >= limit {
return Err(OrderBookError::RiskMaxOpenOrders {
account,
current,
limit,
});
}
}
if let Some(limit) = cfg.max_notional_per_account {
let current = self
.counters
.get(&account)
.map(|c| c.resting_notional.load())
.unwrap_or(0);
let attempted = (quantity as u128).saturating_mul(price);
if current.saturating_add(attempted) > limit {
return Err(OrderBookError::RiskMaxNotional {
account,
current,
attempted,
limit,
});
}
}
self.check_price_band(cfg, price, reference_price)?;
Ok(())
}
#[inline]
fn check_price_band(
&self,
cfg: &RiskConfig,
price: u128,
reference_price: Option<u128>,
) -> Result<(), OrderBookError> {
if let (Some(bps_limit), Some(reference)) = (cfg.price_band_bps, reference_price) {
if reference > 0 {
let diff = price.abs_diff(reference);
let scaled_diff = diff.saturating_mul(10_000);
let band = u128::from(bps_limit).saturating_mul(reference);
if scaled_diff > band {
let bps_u128 = scaled_diff / reference;
let deviation_bps = if bps_u128 > u128::from(u32::MAX) {
u32::MAX
} else {
bps_u128 as u32
};
return Err(OrderBookError::RiskPriceBand {
submitted: price,
reference,
deviation_bps,
limit_bps: bps_limit,
});
}
}
} else if cfg.price_band_bps.is_some()
&& cfg.reference_price.is_some()
&& reference_price.is_none()
{
if self
.warned_no_reference
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
warn!(
"risk: price-band check configured but no reference price available; \
check skipped until a trade or two-sided book establishes a reference"
);
}
}
Ok(())
}
#[inline]
#[allow(clippy::too_many_arguments)]
pub(super) fn check_modify_admission(
&self,
order_id: Id,
account: Hash32,
new_price: u128,
new_qty: u64,
reference_price: Option<u128>,
) -> Result<(), OrderBookError> {
let Some(cfg) = self.config.as_ref() else {
return Ok(());
};
let old_contribution = {
let Some(entry) = self.orders.get(&order_id) else {
return self.check_limit_admission(account, new_price, new_qty, reference_price);
};
u128::from(entry.remaining_qty).saturating_mul(entry.price)
};
if let Some(limit) = cfg.max_notional_per_account {
let current = self
.counters
.get(&account)
.map(|c| c.resting_notional.load())
.unwrap_or(0);
let new_contribution = (new_qty as u128).saturating_mul(new_price);
let projected = current
.saturating_sub(old_contribution)
.saturating_add(new_contribution);
if projected > limit {
return Err(OrderBookError::RiskMaxNotional {
account,
current,
attempted: new_contribution,
limit,
});
}
}
self.check_price_band(cfg, new_price, reference_price)?;
Ok(())
}
#[inline]
pub(super) fn check_market_admission(&self, _account: Hash32) -> Result<(), OrderBookError> {
Ok(())
}
pub(super) fn on_admission(
&self,
order_id: Id,
account: Hash32,
price: u128,
remaining_qty: u64,
) {
if self.config.is_none() {
return;
}
self.orders.insert(
order_id,
RiskEntry {
account,
price,
remaining_qty,
},
);
let counters = self.counters.entry(account).or_default();
counters.open_count.fetch_add(1, Ordering::Relaxed);
let notional_delta = (remaining_qty as u128).saturating_mul(price);
counters.resting_notional.fetch_add(notional_delta);
}
pub(super) fn on_fill(&self, maker_id: Id, filled_qty: u64, maker_price: u128) {
if self.config.is_none() {
return;
}
let (account, entry_price, fully_filled) = {
let Some(mut entry) = self.orders.get_mut(&maker_id) else {
return;
};
let new_remaining = entry.remaining_qty.saturating_sub(filled_qty);
let account = entry.account;
let entry_price = entry.price;
entry.remaining_qty = new_remaining;
(account, entry_price, new_remaining == 0)
};
debug_assert_eq!(
maker_price, entry_price,
"a maker fills at its resting price today; revisit resting_notional accounting before adding price improvement"
);
let notional_delta = (filled_qty as u128).saturating_mul(entry_price);
if let Some(counters_ref) = self.counters.get(&account) {
saturating_sub_u128(&counters_ref.resting_notional, notional_delta);
if fully_filled {
saturating_sub_u64(&counters_ref.open_count, 1);
}
}
if fully_filled {
self.orders.remove(&maker_id);
self.evict_if_zeroed(account);
}
}
#[inline]
fn evict_if_zeroed(&self, account: Hash32) {
self.counters.remove_if(&account, |_, c| {
c.open_count.load(Ordering::Relaxed) == 0 && c.resting_notional.load() == 0
});
}
pub(super) fn on_cancel(&self, order_id: Id) {
if self.config.is_none() {
return;
}
let Some((_, entry)) = self.orders.remove(&order_id) else {
return;
};
let notional_delta = (entry.remaining_qty as u128).saturating_mul(entry.price);
if let Some(counters_ref) = self.counters.get(&entry.account) {
saturating_sub_u64(&counters_ref.open_count, 1);
saturating_sub_u128(&counters_ref.resting_notional, notional_delta);
}
self.evict_if_zeroed(entry.account);
}
pub(super) fn clear(&self) {
self.orders.clear();
self.counters.clear();
}
pub(super) fn rebuild_from_snapshot(
&self,
bids: &[PriceLevelSnapshot],
asks: &[PriceLevelSnapshot],
) {
self.clear();
for level in bids.iter().chain(asks.iter()) {
let price = level.price().as_u128();
for order in level.orders() {
let account = order.user_id();
let remaining_qty = order
.visible_quantity()
.as_u64()
.saturating_add(order.hidden_quantity().as_u64());
self.orders.insert(
order.id(),
RiskEntry {
account,
price,
remaining_qty,
},
);
let counters = self.counters.entry(account).or_default();
counters.open_count.fetch_add(1, Ordering::Relaxed);
let notional_delta = (remaining_qty as u128).saturating_mul(price);
counters.resting_notional.fetch_add(notional_delta);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pricelevel::Id;
fn account(byte: u8) -> Hash32 {
Hash32::new([byte; 32])
}
fn open_count_of(state: &RiskState, acct: Hash32) -> u64 {
state
.counters
.get(&acct)
.map(|c| c.open_count.load(Ordering::Relaxed))
.unwrap_or(0)
}
#[test]
fn test_concurrent_admission_over_admission_is_bounded_issue_116() {
use std::sync::{Arc, Barrier};
use std::thread;
const THREADS: usize = 16;
const LIMIT: u64 = 4;
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(LIMIT));
let state = Arc::new(state);
let acct = account(7);
let barrier = Arc::new(Barrier::new(THREADS));
let handles: Vec<_> = (0..THREADS)
.map(|i| {
let state = Arc::clone(&state);
let barrier = Arc::clone(&barrier);
thread::spawn(move || {
barrier.wait();
if state.check_limit_admission(acct, 100, 1, Some(100)).is_ok() {
state.on_admission(Id::from_u64(i as u64), acct, 100, 1);
1u64
} else {
0
}
})
})
.collect();
let admitted: u64 = handles
.into_iter()
.map(|h| h.join().expect("admission thread"))
.sum();
let open_count = open_count_of(&state, acct);
assert_eq!(
open_count, admitted,
"open_count must match the admissions that incremented it"
);
assert!(open_count >= LIMIT, "at least the limit is admitted");
assert!(
open_count <= LIMIT + THREADS as u64,
"over-admission must stay bounded by limit + thread_count, got {open_count}"
);
}
#[test]
fn test_concurrent_fill_cancel_never_wraps_open_count_issue_116() {
use std::sync::{Arc, Barrier};
use std::thread;
const ORDERS: u64 = 32;
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(10_000));
let acct = account(9);
for i in 0..ORDERS {
state.on_admission(Id::from_u64(i), acct, 100, 10);
}
assert_eq!(open_count_of(&state, acct), ORDERS);
let state = Arc::new(state);
let barrier = Arc::new(Barrier::new((ORDERS * 2) as usize));
let mut handles = Vec::new();
for i in 0..ORDERS {
for which in 0..2u8 {
let state = Arc::clone(&state);
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
barrier.wait();
if which == 0 {
state.on_fill(Id::from_u64(i), 10, 100); } else {
state.on_cancel(Id::from_u64(i));
}
}));
}
}
for h in handles {
h.join().expect("fill/cancel thread");
}
let open_count = open_count_of(&state, acct);
let resting_notional = state
.counters
.get(&acct)
.map(|c| c.resting_notional.load())
.unwrap_or(0);
assert_eq!(open_count, 0, "all orders removed exactly once; no wrap");
assert_eq!(
resting_notional, 0,
"resting_notional also reaches 0 without wrap"
);
}
#[test]
fn test_risk_config_builder() {
let cfg = RiskConfig::new()
.with_max_open_orders_per_account(5)
.with_max_notional_per_account(1_000_000)
.with_price_band_bps(500, ReferencePriceSource::LastTrade);
assert_eq!(cfg.max_open_orders_per_account, Some(5));
assert_eq!(cfg.max_notional_per_account, Some(1_000_000));
assert_eq!(cfg.price_band_bps, Some(500));
assert_eq!(cfg.reference_price, Some(ReferencePriceSource::LastTrade));
}
#[test]
fn test_risk_state_no_config_is_passthrough() {
let state = RiskState::new();
let acct = account(1);
let order_id = Id::new_uuid();
assert!(
state
.check_limit_admission(acct, 100, 10, Some(100))
.is_ok()
);
assert!(state.check_market_admission(acct).is_ok());
state.on_admission(order_id, acct, 100, 10);
state.on_fill(order_id, 5, 100);
state.on_cancel(order_id);
assert!(state.counters.is_empty());
assert!(state.orders.is_empty());
}
#[test]
fn test_on_admission_then_on_cancel_round_trip() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new()
.with_max_open_orders_per_account(10)
.with_max_notional_per_account(1_000_000),
);
let acct = account(2);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 10);
let counters = state
.counters
.get(&acct)
.expect("counters entry created on admission");
assert_eq!(counters.open_count.load(Ordering::Relaxed), 1);
assert_eq!(counters.resting_notional.load(), 1_000);
drop(counters);
state.on_cancel(order_id);
assert!(
state.counters.get(&acct).is_none(),
"counters entry evicted after the account's last order is cancelled"
);
assert!(state.counters.is_empty());
assert!(!state.orders.contains_key(&order_id));
}
#[test]
fn test_on_fill_full_evicts_counters_issue_115() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(1_000_000));
let acct = account(4);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 10);
state.on_fill(order_id, 10, 100);
assert!(
state.counters.get(&acct).is_none(),
"counters entry evicted after the account's last order is fully filled"
);
assert!(state.counters.is_empty());
assert!(!state.orders.contains_key(&order_id));
}
#[test]
fn test_admission_fill_cancel_notional_self_balances_issue_115() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(1_000_000));
let acct = account(5);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 10);
assert_eq!(
state
.counters
.get(&acct)
.map(|c| c.resting_notional.load())
.unwrap_or(0),
1_000
);
state.on_fill(order_id, 4, 100);
let counters = state
.counters
.get(&acct)
.expect("entry retained on partial");
assert_eq!(counters.open_count.load(Ordering::Relaxed), 1);
assert_eq!(counters.resting_notional.load(), 600);
drop(counters);
state.on_cancel(order_id);
assert!(
state.counters.get(&acct).is_none(),
"counters self-balance to zero and evict after the last release"
);
assert!(state.orders.is_empty());
}
#[test]
fn test_concurrent_admission_vs_eviction_is_consistent_issue_115() {
use std::sync::{Arc, Barrier};
use std::thread;
const ROUNDS: usize = 500;
let acct = account(21);
let a = Id::from_u64(1);
let b = Id::from_u64(2);
for round in 0..ROUNDS {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(10_000));
state.on_admission(a, acct, 100, 1);
let state = Arc::new(state);
let barrier = Arc::new(Barrier::new(2));
let (s1, b1) = (Arc::clone(&state), Arc::clone(&barrier));
let t1 = thread::spawn(move || {
b1.wait();
s1.on_fill(a, 1, 100); });
let (s2, b2) = (Arc::clone(&state), Arc::clone(&barrier));
let t2 = thread::spawn(move || {
b2.wait();
s2.on_admission(b, acct, 100, 1); });
t1.join().expect("fill thread");
t2.join().expect("admission thread");
assert!(
!state.orders.contains_key(&a),
"round {round}: A fully filled"
);
assert!(
state.orders.contains_key(&b),
"round {round}: B's entry survives"
);
let resting = state.orders.iter().filter(|e| e.account == acct).count() as u64;
assert_eq!(
open_count_of(&state, acct),
resting,
"round {round}: open_count must track the live resting-order count, never under/overcount"
);
state.on_cancel(b);
assert!(
state.counters.get(&acct).is_none(),
"round {round}: account evicted once its last order is removed"
);
assert!(
state.orders.is_empty(),
"round {round}: no stranded entries"
);
}
}
#[test]
fn test_on_fill_partial_keeps_open_count() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(1_000_000));
let acct = account(3);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 10);
state.on_fill(order_id, 4, 100);
let counters = state.counters.get(&acct).expect("counters entry present");
assert_eq!(
counters.open_count.load(Ordering::Relaxed),
1,
"partial fill must not drop open_count"
);
assert_eq!(
counters.resting_notional.load(),
6 * 100,
"notional must be reduced by filled_qty * price"
);
let entry = state
.orders
.get(&order_id)
.expect("entry retained after partial fill");
assert_eq!(entry.remaining_qty, 6);
}
#[test]
fn test_on_fill_full_decrements_open_count() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(10));
let acct = account(4);
let keep = Id::new_uuid();
let fill = Id::new_uuid();
state.on_admission(keep, acct, 100, 10);
state.on_admission(fill, acct, 100, 10);
state.on_fill(fill, 10, 100);
let counters = state
.counters
.get(&acct)
.expect("entry retained while the account still has a resting order");
assert_eq!(counters.open_count.load(Ordering::Relaxed), 1);
assert_eq!(counters.resting_notional.load(), 1_000);
assert!(!state.orders.contains_key(&fill));
assert!(state.orders.contains_key(&keep));
}
#[test]
fn test_check_limit_admission_max_open_orders_breach_returns_typed_error() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(2));
let acct = account(5);
state.on_admission(Id::new_uuid(), acct, 100, 1);
state.on_admission(Id::new_uuid(), acct, 100, 1);
let err = state
.check_limit_admission(acct, 100, 1, Some(100))
.expect_err("third admission must breach max_open_orders");
match err {
OrderBookError::RiskMaxOpenOrders {
account: a,
current,
limit,
} => {
assert_eq!(a, acct);
assert_eq!(current, 2);
assert_eq!(limit, 2);
}
other => panic!("expected RiskMaxOpenOrders, got {other:?}"),
}
}
#[test]
fn test_check_limit_admission_max_notional_breach_returns_typed_error() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(1_000));
let acct = account(6);
state.on_admission(Id::new_uuid(), acct, 100, 8);
let err = state
.check_limit_admission(acct, 100, 3, Some(100))
.expect_err("notional should be exceeded");
match err {
OrderBookError::RiskMaxNotional {
account: a,
current,
attempted,
limit,
} => {
assert_eq!(a, acct);
assert_eq!(current, 800);
assert_eq!(attempted, 300);
assert_eq!(limit, 1_000);
}
other => panic!("expected RiskMaxNotional, got {other:?}"),
}
}
#[test]
fn test_check_limit_admission_price_band_breach_returns_typed_error() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new().with_price_band_bps(100, ReferencePriceSource::LastTrade),
);
let acct = account(7);
let err = state
.check_limit_admission(acct, 1_100_000, 1, Some(1_000_000))
.expect_err("price band should be exceeded");
match err {
OrderBookError::RiskPriceBand {
submitted,
reference,
deviation_bps,
limit_bps,
} => {
assert_eq!(submitted, 1_100_000);
assert_eq!(reference, 1_000_000);
assert_eq!(deviation_bps, 1_000); assert_eq!(limit_bps, 100);
}
other => panic!("expected RiskPriceBand, got {other:?}"),
}
}
#[test]
fn test_check_limit_admission_price_band_fractional_bps_is_rejected() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new().with_price_band_bps(100, ReferencePriceSource::LastTrade),
);
let acct = account(11);
match state.check_limit_admission(acct, 30_301, 1, Some(30_000)) {
Err(OrderBookError::RiskPriceBand {
deviation_bps,
limit_bps,
..
}) => {
assert_eq!(limit_bps, 100);
assert_eq!(deviation_bps, 100, "display still shows the floored bps");
}
other => panic!("fractional over-band order must be rejected, got {other:?}"),
}
assert!(
state
.check_limit_admission(acct, 30_300, 1, Some(30_000))
.is_ok(),
"exact-limit order must still be admitted"
);
assert!(
state
.check_limit_admission(acct, 30_299, 1, Some(30_000))
.is_ok()
);
}
#[test]
fn test_check_limit_admission_no_reference_price_skips_band_check() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new().with_price_band_bps(100, ReferencePriceSource::LastTrade),
);
assert!(
state
.check_limit_admission(account(8), 999_999_999, 1, None)
.is_ok()
);
}
#[test]
fn test_check_limit_admission_warns_only_once_when_no_reference_available() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new().with_price_band_bps(100, ReferencePriceSource::LastTrade),
);
let acct = account(9);
assert!(state.check_limit_admission(acct, 1, 1, None).is_ok());
assert!(
state.warned_no_reference.load(Ordering::Relaxed),
"first call without reference should flip the latch"
);
assert!(state.check_limit_admission(acct, 2, 2, None).is_ok());
assert!(state.warned_no_reference.load(Ordering::Relaxed));
}
#[test]
fn test_within_limits_admission_succeeds() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new()
.with_max_open_orders_per_account(10)
.with_max_notional_per_account(1_000_000)
.with_price_band_bps(500, ReferencePriceSource::LastTrade),
);
let acct = account(10);
assert!(state.check_limit_admission(acct, 100, 5, Some(100)).is_ok());
}
#[test]
fn test_disable_keeps_counters() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(10));
let acct = account(11);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 10);
state.disable();
assert!(state.config().is_none());
assert!(state.counters.contains_key(&acct));
assert!(state.orders.contains_key(&order_id));
assert!(
state
.check_limit_admission(acct, 100, 100, Some(100))
.is_ok()
);
}
#[test]
fn test_on_fill_overshoot_clamps_counters_at_zero() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(10_000));
let acct = account(12);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 5);
state.on_fill(order_id, 1_000_000, 100);
assert!(
state.counters.get(&acct).is_none(),
"overshoot fill saturates to zero and evicts; a wrap would leave a non-zero count and retain the entry"
);
assert!(state.orders.is_empty());
}
#[test]
fn test_on_cancel_after_fully_filled_is_noop_and_does_not_wrap() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(10));
let acct = account(13);
let order_id = Id::new_uuid();
state.on_admission(order_id, acct, 100, 5);
state.on_fill(order_id, 5, 100); state.on_cancel(order_id);
assert!(
state.counters.get(&acct).is_none(),
"fill evicted the zeroed entry; the later cancel is a no-op and cannot wrap"
);
assert!(state.orders.is_empty());
}
#[test]
fn test_check_modify_admission_no_config_is_passthrough() {
let state = RiskState::new();
assert!(
state
.check_modify_admission(Id::new_uuid(), account(1), 999_999, 999, Some(100))
.is_ok()
);
}
#[test]
fn test_check_modify_admission_ignores_open_order_count() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(1));
let acct = account(20);
let id = Id::new_uuid();
state.on_admission(id, acct, 100, 10);
assert!(
state
.check_modify_admission(id, acct, 110, 10, Some(105))
.is_ok(),
"modify of a tracked order must not be gated by max_open_orders_per_account"
);
}
#[test]
fn test_check_modify_admission_projects_notional_swapping_old_for_new() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(1_000));
let acct = account(21);
let id = Id::new_uuid();
state.on_admission(id, acct, 100, 8);
assert!(
state
.check_modify_admission(id, acct, 100, 9, Some(100))
.is_ok(),
"projected notional 900 must be within the 1_000 ceiling"
);
match state.check_modify_admission(id, acct, 100, 11, Some(100)) {
Err(OrderBookError::RiskMaxNotional {
account: a,
attempted,
limit,
..
}) => {
assert_eq!(a, acct);
assert_eq!(attempted, 1_100);
assert_eq!(limit, 1_000);
}
other => panic!("expected RiskMaxNotional, got {other:?}"),
}
}
#[test]
fn test_check_modify_admission_projection_does_not_double_count_original() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_notional_per_account(1_000));
let acct = account(22);
let id = Id::new_uuid();
state.on_admission(id, acct, 100, 10);
assert!(
state
.check_modify_admission(id, acct, 200, 5, Some(150))
.is_ok(),
"an unchanged-notional modify must not double-count the original"
);
}
#[test]
fn test_check_modify_admission_price_band_on_new_price() {
let mut state = RiskState::new();
state.set_config(
RiskConfig::new().with_price_band_bps(100, ReferencePriceSource::LastTrade),
);
let acct = account(23);
let id = Id::new_uuid();
state.on_admission(id, acct, 1_000_000, 1);
match state.check_modify_admission(id, acct, 1_100_000, 1, Some(1_000_000)) {
Err(OrderBookError::RiskPriceBand {
submitted,
reference,
limit_bps,
..
}) => {
assert_eq!(submitted, 1_100_000);
assert_eq!(reference, 1_000_000);
assert_eq!(limit_bps, 100);
}
other => panic!("expected RiskPriceBand, got {other:?}"),
}
assert!(
state
.check_modify_admission(id, acct, 1_005_000, 1, Some(1_000_000))
.is_ok()
);
}
#[test]
fn test_check_modify_admission_untracked_original_runs_full_admission() {
let mut state = RiskState::new();
state.set_config(RiskConfig::new().with_max_open_orders_per_account(1));
let acct = account(24);
state.on_admission(Id::new_uuid(), acct, 100, 10);
let untracked = Id::new_uuid();
match state.check_modify_admission(untracked, acct, 110, 5, Some(105)) {
Err(OrderBookError::RiskMaxOpenOrders { .. }) => {}
other => panic!(
"untracked modify must run full admission and reject on open count, got {other:?}"
),
}
}
}