use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use crate::core::{HasAccountId, HasFee, HasInstrument, HasPnl};
use crate::param::{AccountId, Asset, Pnl};
use crate::pretrade::policy::{
missing_required_field_account_block, missing_required_field_reject, PolicyGroupId, PolicyName,
};
use crate::pretrade::DEFAULT_POLICY_GROUP_ID;
use crate::pretrade::{
AccountBlock, PostTradeResult, PreTradeContext, PreTradePolicy, Reject, RejectCode,
RejectScope, Rejects,
};
use crate::storage::{ConfigCell, Storage, StorageBuilder};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PnlBoundsBrokerBarrier {
pub settlement_asset: Asset,
pub lower_bound: Option<Pnl>,
pub upper_bound: Option<Pnl>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PnlBoundsAccountAssetBarrier {
pub barrier: PnlBoundsBrokerBarrier,
pub account_id: AccountId,
pub initial_pnl: Pnl,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PnlBoundsAccountAssetBarrierUpdate {
pub barrier: PnlBoundsBrokerBarrier,
pub account_id: AccountId,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PnlBoundsKillSwitchPolicyError {
NoBarriersConfigured,
NoBoundsConfigured { settlement_asset: Asset },
}
impl Display for PnlBoundsKillSwitchPolicyError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoBarriersConfigured => write!(
f,
"at least one broker or account+asset barrier must be configured"
),
Self::NoBoundsConfigured { settlement_asset } => write!(
f,
"at least one of lower_bound or upper_bound must be configured \
for settlement asset {settlement_asset}"
),
}
}
}
impl std::error::Error for PnlBoundsKillSwitchPolicyError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PnlBoundsKillSwitchSettings {
account_barriers: HashMap<AccountId, HashMap<Asset, PnlBoundsAccountAssetBarrierUpdate>>,
broker_barriers: HashMap<Asset, PnlBoundsBrokerBarrier>,
initial_pnl: HashMap<(AccountId, Asset), Pnl>,
group_id: PolicyGroupId,
}
impl PnlBoundsKillSwitchSettings {
pub fn new(
broker_barriers: impl IntoIterator<Item = PnlBoundsBrokerBarrier>,
account_barriers: impl IntoIterator<Item = PnlBoundsAccountAssetBarrier>,
) -> Result<Self, PnlBoundsKillSwitchPolicyError> {
let mut broker = HashMap::new();
for barrier in broker_barriers {
validate_bounds(
&barrier.lower_bound,
&barrier.upper_bound,
&barrier.settlement_asset,
)?;
broker.insert(barrier.settlement_asset.clone(), barrier);
}
let mut account = HashMap::new();
let mut initial_pnl = HashMap::new();
for barrier in account_barriers {
validate_bounds(
&barrier.barrier.lower_bound,
&barrier.barrier.upper_bound,
&barrier.barrier.settlement_asset,
)?;
let account_id = barrier.account_id;
let settlement_asset = barrier.barrier.settlement_asset.clone();
initial_pnl.insert((account_id, settlement_asset.clone()), barrier.initial_pnl);
account
.entry(account_id)
.or_insert_with(HashMap::new)
.insert(
settlement_asset,
PnlBoundsAccountAssetBarrierUpdate {
barrier: barrier.barrier,
account_id,
},
);
}
if broker.is_empty() && account.is_empty() {
return Err(PnlBoundsKillSwitchPolicyError::NoBarriersConfigured);
}
Ok(Self {
account_barriers: account,
broker_barriers: broker,
initial_pnl,
group_id: DEFAULT_POLICY_GROUP_ID,
})
}
pub fn set_broker_barriers(
&mut self,
barriers: impl IntoIterator<Item = PnlBoundsBrokerBarrier>,
) -> Result<(), PnlBoundsKillSwitchPolicyError> {
let mut broker = HashMap::new();
for barrier in barriers {
validate_bounds(
&barrier.lower_bound,
&barrier.upper_bound,
&barrier.settlement_asset,
)?;
broker.insert(barrier.settlement_asset.clone(), barrier);
}
if broker.is_empty() && self.account_barriers.is_empty() {
return Err(PnlBoundsKillSwitchPolicyError::NoBarriersConfigured);
}
self.broker_barriers = broker;
Ok(())
}
pub fn set_account_barriers(
&mut self,
barriers: impl IntoIterator<Item = PnlBoundsAccountAssetBarrierUpdate>,
) -> Result<(), PnlBoundsKillSwitchPolicyError> {
let mut account = HashMap::new();
for barrier in barriers {
validate_bounds(
&barrier.barrier.lower_bound,
&barrier.barrier.upper_bound,
&barrier.barrier.settlement_asset,
)?;
account
.entry(barrier.account_id)
.or_insert_with(HashMap::new)
.insert(barrier.barrier.settlement_asset.clone(), barrier);
}
if self.broker_barriers.is_empty() && account.is_empty() {
return Err(PnlBoundsKillSwitchPolicyError::NoBarriersConfigured);
}
self.account_barriers = account;
Ok(())
}
}
pub(crate) type RealizedPnlStorage<LockingPolicyFactory> =
<LockingPolicyFactory as crate::storage::LockingPolicyFactory>::Shared<
Storage<
(AccountId, Asset),
Pnl,
<LockingPolicyFactory as crate::storage::LockingPolicyFactory>::Policy,
>,
>;
pub struct PnlBoundsKillSwitchPolicy<LockingPolicyFactory>
where
LockingPolicyFactory: crate::storage::LockingPolicyFactory,
{
settings: <LockingPolicyFactory as crate::storage::LockingPolicyFactory>::Config<
PnlBoundsKillSwitchSettings,
>,
realized: RealizedPnlStorage<LockingPolicyFactory>,
}
impl<LockingPolicyFactory> PnlBoundsKillSwitchPolicy<LockingPolicyFactory>
where
LockingPolicyFactory: crate::storage::LockingPolicyFactory,
{
pub const NAME: &'static str = "PnlBoundsKillSwitchPolicy";
pub fn new(
mut settings: PnlBoundsKillSwitchSettings,
storage_builder: &StorageBuilder<LockingPolicyFactory>,
) -> Self
where
LockingPolicyFactory: crate::storage::CreateStorageFor<(AccountId, Asset)>,
{
let realized = storage_builder.create_for_bound_key();
let initial_pnl = std::mem::take(&mut settings.initial_pnl);
for ((account_id, settlement_asset), initial_pnl) in initial_pnl {
realized.with_mut(
(account_id, settlement_asset),
|| Pnl::ZERO,
|entry, _is_new| {
*entry = initial_pnl;
},
);
}
let realized =
<LockingPolicyFactory as crate::storage::LockingPolicyFactory>::new_shared(realized);
Self {
settings: <LockingPolicyFactory as crate::storage::LockingPolicyFactory>::new_config(
settings,
),
realized,
}
}
pub fn with_policy_group_id(self, id: PolicyGroupId) -> Self {
self.settings
.update::<std::convert::Infallible>(|s| {
s.group_id = id;
Ok(())
})
.unwrap_or_else(|e| match e {});
self
}
}
impl<LockingPolicyFactory> PolicyName for PnlBoundsKillSwitchPolicy<LockingPolicyFactory>
where
LockingPolicyFactory: crate::storage::LockingPolicyFactory,
{
fn policy_name(&self) -> &str {
Self::NAME
}
}
impl<LockingPolicyFactory> crate::pretrade::ConfigurablePolicy<LockingPolicyFactory>
for PnlBoundsKillSwitchPolicy<LockingPolicyFactory>
where
LockingPolicyFactory: crate::storage::LockingPolicyFactory,
{
type Settings = PnlBoundsKillSwitchSettings;
fn settings_cell(
&self,
) -> <LockingPolicyFactory as crate::storage::LockingPolicyFactory>::Config<
PnlBoundsKillSwitchSettings,
> {
self.settings.clone()
}
}
impl<Order, ExecutionReport, AccountAdjustment, LockingPolicyFactory, Sync>
PreTradePolicy<Order, ExecutionReport, AccountAdjustment, Sync>
for PnlBoundsKillSwitchPolicy<LockingPolicyFactory>
where
Order: HasInstrument + HasAccountId,
ExecutionReport: HasInstrument + HasPnl + HasFee + HasAccountId,
LockingPolicyFactory: crate::storage::LockingPolicyFactory
+ crate::storage::CreateStorageFor<AccountId>
+ 'static,
Sync: crate::core::SyncMode<StorageLockingPolicyFactory = LockingPolicyFactory>,
{
fn name(&self) -> &str {
Self::NAME
}
fn policy_group_id(&self) -> PolicyGroupId {
self.settings.with(|s| s.group_id)
}
#[allow(private_interfaces)]
fn built_in_config_entry(&self) -> Option<crate::core::ConfigEntry<LockingPolicyFactory>> {
Some(crate::core::ConfigEntry::PnlBoundsKillSwitch {
settings: crate::pretrade::ConfigurablePolicy::settings_cell(self),
realized: self.realized.clone(),
})
}
fn check_pre_trade_start(
&self,
_ctx: &PreTradeContext<<Sync as crate::core::SyncMode>::StorageLockingPolicyFactory>,
order: &Order,
) -> Result<(), Rejects> {
let instrument = order
.instrument()
.map_err(|e| Rejects::from(missing_required_field_reject(self, "instrument", &e)))?;
let account_id = order
.account_id()
.map_err(|e| Rejects::from(missing_required_field_reject(self, "account ID", &e)))?;
let settlement = instrument.settlement_asset();
let (broker_breach, account_breach) = self.settings.with(|s| {
let broker_barrier = s.broker_barriers.get(settlement);
let account_barrier = s
.account_barriers
.get(&account_id)
.and_then(|m| m.get(settlement));
if broker_barrier.is_none() && account_barrier.is_none() {
return (None, None);
}
let current_pnl = self
.realized
.with(&(account_id, settlement.clone()), |entry| *entry)
.unwrap_or(Pnl::ZERO);
let bb = broker_barrier.and_then(|b| {
let sides = breached_sides(b.lower_bound, b.upper_bound, current_pnl);
if sides.is_empty() {
None
} else {
Some(barrier_breach_reject(
Self::NAME,
"pnl kill switch triggered: broker barrier",
&sides,
b,
current_pnl,
account_id,
))
}
});
let ab = account_barrier.and_then(|a| {
let sides =
breached_sides(a.barrier.lower_bound, a.barrier.upper_bound, current_pnl);
if sides.is_empty() {
None
} else {
Some(barrier_breach_reject(
Self::NAME,
"pnl kill switch triggered: account + asset barrier",
&sides,
&a.barrier,
current_pnl,
account_id,
))
}
});
(bb, ab)
});
if let Some(reject) = broker_breach {
return Err(reject.into());
}
if let Some(reject) = account_breach {
return Err(reject.into());
}
Ok(())
}
fn apply_execution_report(
&self,
_ctx: &crate::pretrade::PostTradeContext<
<Sync as crate::core::SyncMode>::StorageLockingPolicyFactory,
>,
report: &ExecutionReport,
) -> Option<PostTradeResult> {
let instrument = match report.instrument() {
Ok(i) => i,
Err(e) => {
return Some(PostTradeResult::blocks_only(vec![
missing_required_field_account_block(self, "instrument", &e),
]))
}
};
let account_id = match report.account_id() {
Ok(id) => id,
Err(e) => {
return Some(PostTradeResult::blocks_only(vec![
missing_required_field_account_block(self, "account ID", &e),
]))
}
};
let pnl_delta = match report.pnl() {
Ok(p) => p,
Err(e) => {
return Some(PostTradeResult::blocks_only(vec![
missing_required_field_account_block(self, "P&L", &e),
]))
}
};
let fee = match report.fee() {
Ok(f) => f,
Err(e) => {
return Some(PostTradeResult::blocks_only(vec![
missing_required_field_account_block(self, "fee", &e),
]))
}
};
let settlement = instrument.settlement_asset();
let pnl_with_fee = match pnl_delta.checked_add(fee.to_pnl()) {
Ok(v) => v,
Err(_) => {
return Some(PostTradeResult::blocks_only(vec![
pnl_calculation_failed_block(
self,
format!(
"pnl + fee overflow: pnl {pnl_delta}, fee {fee}, \
settlement asset {settlement}, account {account_id}"
),
),
]));
}
};
let block: Option<AccountBlock> = self.realized.with_mut(
(account_id, settlement.clone()),
|| Pnl::ZERO,
|entry, _is_new| {
let previous = *entry;
let updated = match previous.checked_add(pnl_with_fee) {
Ok(value) => value,
Err(_) => {
return Some(pnl_calculation_failed_block(
self,
format!(
"realized pnl + pnl_with_fee overflow: \
previous {previous}, increment {pnl_with_fee}, \
settlement asset {settlement}, account {account_id}"
),
));
}
};
*entry = updated;
let outside = self.settings.with(|s| {
is_outside_bounds(
&s.broker_barriers,
&s.account_barriers,
updated,
settlement,
account_id,
)
});
if outside {
Some(AccountBlock::new(
Self::NAME,
RejectCode::PnlKillSwitchTriggered,
"pnl kill switch triggered",
format!(
"realized pnl {updated}, settlement asset {settlement}, \
account {account_id}"
),
))
} else {
None
}
},
);
block.map(|b| PostTradeResult::blocks_only(vec![b]))
}
}
fn barrier_breach_reject(
policy_name: &'static str,
reason: &'static str,
breached_sides: &[&'static str],
barrier: &PnlBoundsBrokerBarrier,
realized: Pnl,
account_id: AccountId,
) -> Reject {
let desc = breached_sides.join(" and ");
let settlement = &barrier.settlement_asset;
let lower_bound = barrier.lower_bound;
let upper_bound = barrier.upper_bound;
Reject::new(
policy_name,
RejectScope::Account,
RejectCode::PnlKillSwitchTriggered,
reason,
format!(
"{desc} bound breached: realized pnl {realized}, \
lower_bound {lower_bound:?}, upper_bound {upper_bound:?}, \
settlement asset {settlement}, account {account_id}"
),
)
}
fn pnl_calculation_failed_block<Policy: PolicyName + ?Sized>(
policy: &Policy,
details: String,
) -> AccountBlock {
AccountBlock::new(
policy.policy_name(),
RejectCode::OrderValueCalculationFailed,
"pnl accumulation overflow",
details,
)
}
fn is_outside_bounds(
broker_barriers: &HashMap<Asset, PnlBoundsBrokerBarrier>,
account_barriers: &HashMap<AccountId, HashMap<Asset, PnlBoundsAccountAssetBarrierUpdate>>,
pnl: Pnl,
settlement: &Asset,
account_id: AccountId,
) -> bool {
if let Some(b) = broker_barriers.get(settlement) {
if !breached_sides(b.lower_bound, b.upper_bound, pnl).is_empty() {
return true;
}
}
if let Some(b) = account_barriers
.get(&account_id)
.and_then(|m| m.get(settlement))
{
if !breached_sides(b.barrier.lower_bound, b.barrier.upper_bound, pnl).is_empty() {
return true;
}
}
false
}
fn breached_sides(
lower_bound: Option<Pnl>,
upper_bound: Option<Pnl>,
realized: Pnl,
) -> Vec<&'static str> {
let mut sides = Vec::new();
if let Some(lb) = lower_bound {
if realized < lb {
sides.push("lower");
}
}
if let Some(ub) = upper_bound {
if realized > ub {
sides.push("upper");
}
}
sides
}
fn validate_bounds(
lower_bound: &Option<Pnl>,
upper_bound: &Option<Pnl>,
settlement_asset: &Asset,
) -> Result<(), PnlBoundsKillSwitchPolicyError> {
if lower_bound.is_none() && upper_bound.is_none() {
return Err(PnlBoundsKillSwitchPolicyError::NoBoundsConfigured {
settlement_asset: settlement_asset.clone(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::core::{HasAccountId, HasFee, HasInstrument, HasPnl, Instrument, OrderOperation};
use crate::param::TradeAmount;
use crate::param::{AccountId, Asset, Fee, Pnl, Price, Quantity, Side};
use crate::pretrade::{PreTradeContext, PreTradePolicy, RejectCode, RejectScope};
use crate::storage::{ConfigCell, NoLocking};
use crate::RequestFieldAccessError;
use super::{
PnlBoundsAccountAssetBarrier, PnlBoundsAccountAssetBarrierUpdate, PnlBoundsBrokerBarrier,
PnlBoundsKillSwitchPolicy, PnlBoundsKillSwitchPolicyError, PnlBoundsKillSwitchSettings,
};
type TestPolicy = PnlBoundsKillSwitchPolicy<NoLocking>;
fn test_builder() -> crate::SyncedEngineBuilder<OrderOperation, TestReport, (), crate::LocalSync>
{
crate::Engine::builder().no_sync()
}
struct TestReport {
instrument: Instrument,
account_id: AccountId,
pnl: Pnl,
fee: Fee,
}
impl HasInstrument for TestReport {
fn instrument(&self) -> Result<&Instrument, crate::RequestFieldAccessError> {
Ok(&self.instrument)
}
}
impl HasAccountId for TestReport {
fn account_id(&self) -> Result<AccountId, crate::RequestFieldAccessError> {
Ok(self.account_id)
}
}
impl HasPnl for TestReport {
fn pnl(&self) -> Result<Pnl, crate::RequestFieldAccessError> {
Ok(self.pnl)
}
}
impl HasFee for TestReport {
fn fee(&self) -> Result<Fee, crate::RequestFieldAccessError> {
Ok(self.fee)
}
}
#[test]
fn happy_path_order_passes_inside_bounds() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
apply_report(&policy, &report("USD", account(1), pnl("-10")));
assert!(check_start(&policy, &order("USD", account(1))).is_ok());
}
#[test]
fn different_accounts_track_pnl_independently() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
apply_report(&policy, &report("USD", account(1), pnl("40")));
apply_report(&policy, &report("USD", account(2), pnl("-90")));
assert!(check_start(&policy, &order("USD", account(1))).is_ok());
assert!(check_start(&policy, &order("USD", account(2))).is_ok());
let triggered = apply_report(&policy, &report("USD", account(1), pnl("15")));
assert!(triggered); assert!(check_start(&policy, &order("USD", account(1))).is_err());
assert!(check_start(&policy, &order("USD", account(2))).is_ok());
}
#[test]
fn apply_breach_blocks_account_lower_bound() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("-101")));
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(blocks[0].reason, "pnl kill switch triggered");
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
let reject = &reject[0];
assert_eq!(reject.scope, RejectScope::Account);
assert_eq!(reject.code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(reject.reason, "pnl kill switch triggered: broker barrier");
}
#[test]
fn apply_breach_blocks_account_upper_bound() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("51")));
assert_eq!(blocks.len(), 1);
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: broker barrier"
);
}
#[test]
fn check_detects_lower_bound_breach_with_specific_reason() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-100")), Some(pnl("50")))],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-500")), None),
account_id: account(1),
initial_pnl: pnl("-101"),
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
let reject = &reject[0];
assert_eq!(reject.scope, RejectScope::Account);
assert_eq!(reject.code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(reject.reason, "pnl kill switch triggered: broker barrier");
assert!(reject.details.contains("lower bound breached"));
let reject2 = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(
reject2[0].reason,
"pnl kill switch triggered: broker barrier"
);
}
#[test]
fn check_detects_upper_bound_breach_with_specific_reason() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-100")), Some(pnl("50")))],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", None, Some(pnl("500"))),
account_id: account(1),
initial_pnl: pnl("51"),
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: broker barrier"
);
assert!(reject[0].details.contains("upper bound breached"));
}
#[test]
fn inverted_bounds_breach_detected_at_check() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("10")), Some(pnl("5")))],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", None, Some(pnl("500"))),
account_id: account(1),
initial_pnl: pnl("7"),
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(reject[0].code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: broker barrier"
);
assert!(reject[0].details.contains("lower and upper bound breached"));
}
#[test]
fn account_barrier_initial_pnl_pre_loaded_into_storage() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-500")), None)],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-100")), None),
account_id: account(1),
initial_pnl: pnl("-90"),
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
assert!(check_start(&policy, &order("USD", account(1))).is_ok());
assert!(check_start(&policy, &order("USD", account(2))).is_ok());
}
#[test]
fn account_barrier_breach_detected_at_check_specific_reason() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-500")), None)],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-100")), None),
account_id: account(1),
initial_pnl: pnl("-200"),
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
let reject = &reject[0];
assert_eq!(reject.code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(
reject.reason,
"pnl kill switch triggered: account + asset barrier"
);
assert!(reject.details.contains("lower bound breached"));
}
#[test]
fn account_barrier_apply_breach_blocks() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-500")), None)],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-100")), None),
account_id: account(1),
initial_pnl: Pnl::ZERO,
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("-200")));
assert_eq!(blocks.len(), 1);
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: account + asset barrier"
);
}
#[test]
fn global_barrier_breach_blocks_even_within_account_bounds() {
let settings = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-100")), None)],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-500")), None),
account_id: account(1),
initial_pnl: Pnl::ZERO,
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("-200")));
assert_eq!(blocks.len(), 1);
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(reject[0].code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: broker barrier"
);
}
#[test]
fn no_barriers_configured_rejected_by_constructor() {
let err = PnlBoundsKillSwitchSettings::new([], []).expect_err("must fail");
assert_eq!(err, PnlBoundsKillSwitchPolicyError::NoBarriersConfigured);
assert_eq!(
err.to_string(),
"at least one broker or account+asset barrier must be configured"
);
}
#[test]
fn missing_bounds_rejected_by_constructor() {
let usd = Asset::new("USD").expect("asset code must be valid");
let err = PnlBoundsKillSwitchSettings::new(
[PnlBoundsBrokerBarrier {
settlement_asset: usd.clone(),
lower_bound: None,
upper_bound: None,
}],
[],
)
.expect_err("must fail");
assert_eq!(
err,
PnlBoundsKillSwitchPolicyError::NoBoundsConfigured {
settlement_asset: usd,
}
);
}
#[test]
fn missing_account_bounds_rejected_by_constructor() {
let usd = Asset::new("USD").expect("must be valid");
let err = PnlBoundsKillSwitchSettings::new(
[barrier_usd(Some(pnl("-100")), None)],
[PnlBoundsAccountAssetBarrier {
barrier: PnlBoundsBrokerBarrier {
settlement_asset: usd.clone(),
lower_bound: None,
upper_bound: None,
},
account_id: account(1),
initial_pnl: Pnl::ZERO,
}],
)
.expect_err("must fail");
assert_eq!(
err,
PnlBoundsKillSwitchPolicyError::NoBoundsConfigured {
settlement_asset: usd
}
);
}
#[test]
fn apply_execution_report_accumulates_pnl_and_reports_trigger() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
assert!(!apply_report(
&policy,
&report("USD", account(1), pnl("40"))
)); assert!(apply_report(&policy, &report("USD", account(1), pnl("15")))); }
#[test]
fn no_barrier_for_settlement_order_passes() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
assert!(check_start(&policy, &order("EUR", account(1))).is_ok());
}
#[test]
fn accumulation_and_breach_detection_independent_per_settlement() {
let settings = PnlBoundsKillSwitchSettings::new(
[
barrier("USD", Some(pnl("-100")), Some(pnl("50"))),
barrier("EUR", Some(pnl("-200")), Some(pnl("100"))),
],
[
PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-50")), None),
account_id: account(1),
initial_pnl: Pnl::ZERO,
},
PnlBoundsAccountAssetBarrier {
barrier: barrier("EUR", Some(pnl("-100")), None),
account_id: account(1),
initial_pnl: Pnl::ZERO,
},
],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
apply_report(&policy, &report("USD", account(1), pnl("-30")));
apply_report(&policy, &report("EUR", account(1), pnl("40")));
assert!(check_start(&policy, &order("USD", account(1))).is_ok());
assert!(check_start(&policy, &order("EUR", account(1))).is_ok());
apply_report(&policy, &report("USD", account(1), pnl("-25")));
assert!(check_start(&policy, &order("USD", account(1))).is_err());
assert!(check_start(&policy, &order("EUR", account(1))).is_ok());
}
#[test]
fn repeated_reports_track_running_total() {
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("-101")));
assert_eq!(blocks.len(), 1);
assert!(check_start(&policy, &order("USD", account(1))).is_err());
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("5")));
assert!(blocks.is_empty());
assert!(check_start(&policy, &order("USD", account(1))).is_ok());
}
#[test]
fn accumulator_overflow_reports_calculation_failure() {
use rust_decimal::Decimal;
let policy = policy_usd(Some(pnl("-100")), None);
let first =
apply_report_blocks(&policy, &report("USD", account(1), Pnl::new(Decimal::MAX)));
assert!(first.is_empty());
let second =
apply_report_blocks(&policy, &report("USD", account(1), Pnl::new(Decimal::MAX)));
assert_eq!(second.len(), 1);
assert_eq!(second[0].code, RejectCode::OrderValueCalculationFailed);
assert_eq!(second[0].reason, "pnl accumulation overflow");
assert!(second[0]
.details
.contains("realized pnl + pnl_with_fee overflow"));
}
#[test]
fn negative_accumulator_overflow_reports_calculation_failure() {
use rust_decimal::Decimal;
let policy = policy_usd(None, Some(pnl("100")));
let first =
apply_report_blocks(&policy, &report("USD", account(1), Pnl::new(Decimal::MIN)));
assert!(first.is_empty());
let second =
apply_report_blocks(&policy, &report("USD", account(1), Pnl::new(Decimal::MIN)));
assert_eq!(second.len(), 1);
assert_eq!(second[0].code, RejectCode::OrderValueCalculationFailed);
}
#[test]
fn pnl_plus_fee_overflow_reports_calculation_failure() {
use rust_decimal::Decimal;
let policy = policy_usd(None, Some(Pnl::new(Decimal::MAX)));
let blocks = apply_report_blocks(
&policy,
&report_with_fee(
"USD",
account(1),
Pnl::new(Decimal::MAX),
Fee::new(-Decimal::ONE),
),
);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::OrderValueCalculationFailed);
assert_eq!(blocks[0].reason, "pnl accumulation overflow");
assert!(blocks[0].details.contains("pnl + fee overflow"));
}
#[test]
fn overflow_does_not_corrupt_subsequent_reports() {
use rust_decimal::Decimal;
let policy = policy_usd(None, Some(Pnl::new(Decimal::MAX)));
let first = apply_report_blocks(
&policy,
&report_with_fee(
"USD",
account(1),
Pnl::new(Decimal::MAX),
Fee::new(-Decimal::ONE),
),
);
assert_eq!(first.len(), 1);
assert_eq!(first[0].code, RejectCode::OrderValueCalculationFailed);
let second = apply_report_blocks(&policy, &report("USD", account(1), Pnl::ZERO));
assert!(second.is_empty());
}
#[test]
fn untracked_settlement_pnl_plus_fee_overflow_produces_block() {
use rust_decimal::Decimal;
let policy = policy_usd(None, Some(Pnl::new(Decimal::MAX)));
let blocks = apply_report_blocks(
&policy,
&report_with_fee(
"EUR",
account(1),
Pnl::new(Decimal::MAX),
Fee::new(-Decimal::ONE),
),
);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::OrderValueCalculationFailed);
assert_eq!(blocks[0].reason, "pnl accumulation overflow");
assert!(blocks[0].details.contains("pnl + fee overflow"));
}
#[test]
fn account_only_barrier_without_global() {
let settings = PnlBoundsKillSwitchSettings::new(
[],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-100")), None),
account_id: account(1),
initial_pnl: Pnl::ZERO,
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let blocks = apply_report_blocks(&policy, &report("USD", account(1), pnl("-150")));
assert_eq!(blocks.len(), 1);
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(reject[0].code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: account + asset barrier"
);
assert!(check_start(&policy, &order("USD", account(2))).is_ok());
assert!(check_start(&policy, &order("EUR", account(1))).is_ok());
}
#[test]
fn check_pre_trade_start_maps_instrument_access_error() {
struct InvalidOrder;
impl HasInstrument for InvalidOrder {
fn instrument(&self) -> Result<&Instrument, RequestFieldAccessError> {
Err(RequestFieldAccessError::new("instrument"))
}
}
impl HasAccountId for InvalidOrder {
fn account_id(&self) -> Result<AccountId, RequestFieldAccessError> {
Ok(account(1))
}
}
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let reject = <TestPolicy as PreTradePolicy<
InvalidOrder,
TestReport,
(),
crate::core::LocalSync,
>>::check_pre_trade_start(
&policy,
&PreTradeContext::<NoLocking>::new(None),
&InvalidOrder,
)
.expect_err("field access error must reject");
let reject = &reject[0];
assert_eq!(reject.scope, RejectScope::Order);
assert_eq!(reject.code, RejectCode::MissingRequiredField);
assert_eq!(
reject.reason,
"failed to access required field 'instrument'"
);
assert_eq!(reject.details, "failed to access field 'instrument'");
}
#[test]
fn settings_cell_clone_shares_underlying_value() {
use crate::pretrade::ConfigurablePolicy;
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let cell = policy.settings_cell();
cell.update::<PnlBoundsKillSwitchPolicyError>(|s| {
s.set_broker_barriers([barrier_usd(Some(pnl("-200")), Some(pnl("200")))])
})
.expect("valid update");
apply_report(&policy, &report("USD", account(1), pnl("100")));
assert!(
check_start(&policy, &order("USD", account(1))).is_ok(),
"updated barrier must be observed by the policy"
);
}
#[test]
fn settings_update_does_not_reset_realized_pnl() {
use crate::pretrade::ConfigurablePolicy;
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
apply_report(&policy, &report("USD", account(1), pnl("40")));
let cell = policy.settings_cell();
cell.update::<PnlBoundsKillSwitchPolicyError>(|s| {
s.set_broker_barriers([barrier_usd(Some(pnl("-100")), Some(pnl("30")))])
})
.expect("valid update");
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(reject[0].code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: broker barrier"
);
}
#[test]
fn set_account_pnl_overrides_live_accumulator() {
use crate::Engine;
let builder = Engine::builder::<OrderOperation, TestReport, ()>().no_sync();
let settings =
PnlBoundsKillSwitchSettings::new([barrier_usd(Some(pnl("-100")), Some(pnl("50")))], [])
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, builder.storage_builder());
let engine = builder
.pre_trade(policy)
.build()
.expect("engine must build");
let name = PnlBoundsKillSwitchPolicy::<NoLocking>::NAME;
let usd = Asset::new("USD").expect("asset code must be valid");
engine
.execute_pre_trade(order("USD", account(1)))
.expect("order must pass with zero P&L");
engine
.configure()
.set_account_pnl(name, account(1), usd.clone(), pnl("-150"))
.expect("force-set must publish");
let reject = engine
.execute_pre_trade(order("USD", account(1)))
.err()
.expect("order must reject after the override breaches the bound");
assert_eq!(reject[0].code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: broker barrier"
);
engine
.configure()
.set_account_pnl(name, account(2), usd, pnl("-10"))
.expect("force-set inside bounds must publish");
engine
.execute_pre_trade(order("USD", account(2)))
.expect("order must pass after the accumulator is set inside bounds");
}
#[test]
fn barrier_added_at_runtime_sees_pre_accumulated_pnl() {
use crate::pretrade::ConfigurablePolicy;
let settings = PnlBoundsKillSwitchSettings::new(
[],
[PnlBoundsAccountAssetBarrier {
barrier: barrier("USD", Some(pnl("-500")), None),
account_id: account(2),
initial_pnl: Pnl::ZERO,
}],
)
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
apply_report(&policy, &report("USD", account(1), pnl("-80")));
apply_report(&policy, &report("USD", account(1), pnl("-40")));
assert!(check_start(&policy, &order("USD", account(1))).is_ok());
let cell = policy.settings_cell();
cell.update::<PnlBoundsKillSwitchPolicyError>(|settings| {
settings.set_account_barriers([
PnlBoundsAccountAssetBarrierUpdate {
barrier: barrier("USD", Some(pnl("-500")), None),
account_id: account(2),
},
PnlBoundsAccountAssetBarrierUpdate {
barrier: barrier("USD", Some(pnl("-100")), None),
account_id: account(1),
},
])
})
.expect("barrier add must succeed");
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(
reject[0].reason,
"pnl kill switch triggered: account + asset barrier"
);
}
#[test]
fn settings_update_invalid_leaves_prior_value() {
use crate::pretrade::ConfigurablePolicy;
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let cell = policy.settings_cell();
let result = cell.update::<PnlBoundsKillSwitchPolicyError>(|s| s.set_broker_barriers([]));
assert_eq!(
result,
Err(PnlBoundsKillSwitchPolicyError::NoBarriersConfigured)
);
apply_report(&policy, &report("USD", account(1), pnl("51")));
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
assert_eq!(reject[0].code, RejectCode::PnlKillSwitchTriggered);
}
#[test]
fn set_account_barriers_validated_before_apply() {
let mut settings =
PnlBoundsKillSwitchSettings::new([barrier_usd(Some(pnl("-100")), None)], [])
.expect("valid");
let usd = Asset::new("USD").expect("valid");
let err = settings
.set_account_barriers([PnlBoundsAccountAssetBarrierUpdate {
barrier: PnlBoundsBrokerBarrier {
settlement_asset: usd.clone(),
lower_bound: None,
upper_bound: None,
},
account_id: account(1),
}])
.expect_err("must fail");
assert_eq!(
err,
PnlBoundsKillSwitchPolicyError::NoBoundsConfigured {
settlement_asset: usd,
}
);
assert!(settings
.broker_barriers
.contains_key(&Asset::new("USD").expect("valid")));
}
#[test]
fn with_policy_group_id_observed_via_policy_group_id() {
use crate::pretrade::PolicyGroupId;
let policy =
policy_usd(Some(pnl("-100")), None).with_policy_group_id(PolicyGroupId::new(7));
let observed = <TestPolicy as PreTradePolicy<
OrderOperation,
TestReport,
(),
crate::core::LocalSync,
>>::policy_group_id(&policy);
assert_eq!(observed, PolicyGroupId::new(7));
}
fn check_start(
policy: &TestPolicy,
order: &OrderOperation,
) -> Result<(), crate::pretrade::Rejects> {
<TestPolicy as PreTradePolicy<OrderOperation, TestReport, (), crate::core::LocalSync>>::check_pre_trade_start(
policy,
&PreTradeContext::<NoLocking>::new(None),
order,
)
}
fn apply_report(policy: &TestPolicy, report: &TestReport) -> bool {
!apply_report_blocks(policy, report).is_empty()
}
fn apply_report_blocks(
policy: &TestPolicy,
report: &TestReport,
) -> Vec<crate::pretrade::AccountBlock> {
<TestPolicy as PreTradePolicy<OrderOperation, TestReport, (), crate::core::LocalSync>>::apply_execution_report(
policy,
&crate::pretrade::PostTradeContext::new(),
report,
)
.map(|r| r.account_blocks)
.unwrap_or_default()
}
fn report(settlement: &str, account_id: AccountId, pnl_val: Pnl) -> TestReport {
TestReport {
instrument: Instrument::new(
Asset::new("AAPL").expect("must be valid"),
Asset::new(settlement).expect("must be valid"),
),
account_id,
pnl: pnl_val,
fee: Fee::ZERO,
}
}
fn report_with_fee(
settlement: &str,
account_id: AccountId,
pnl_val: Pnl,
fee: Fee,
) -> TestReport {
TestReport {
instrument: Instrument::new(
Asset::new("AAPL").expect("must be valid"),
Asset::new(settlement).expect("must be valid"),
),
account_id,
pnl: pnl_val,
fee,
}
}
fn policy_usd(lower_bound: Option<Pnl>, upper_bound: Option<Pnl>) -> TestPolicy {
let settings =
PnlBoundsKillSwitchSettings::new([barrier_usd(lower_bound, upper_bound)], [])
.expect("settings must be valid");
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder())
}
fn barrier_usd(lower_bound: Option<Pnl>, upper_bound: Option<Pnl>) -> PnlBoundsBrokerBarrier {
barrier("USD", lower_bound, upper_bound)
}
fn barrier(
settlement: &str,
lower_bound: Option<Pnl>,
upper_bound: Option<Pnl>,
) -> PnlBoundsBrokerBarrier {
PnlBoundsBrokerBarrier {
settlement_asset: Asset::new(settlement).expect("must be valid"),
lower_bound,
upper_bound,
}
}
fn order(settlement: &str, account_id: AccountId) -> OrderOperation {
OrderOperation {
instrument: Instrument::new(
Asset::new("AAPL").expect("must be valid"),
Asset::new(settlement).expect("must be valid"),
),
account_id,
side: Side::Buy,
trade_amount: TradeAmount::Quantity(
Quantity::from_str("1").expect("quantity literal must be valid"),
),
price: Some(Price::from_str("100").expect("price literal must be valid")),
}
}
fn account(id: u64) -> AccountId {
AccountId::from_u64(id)
}
fn pnl(value: &str) -> Pnl {
Pnl::from_str(value).expect("pnl literal must be valid")
}
#[test]
fn missing_instrument_in_report_returns_account_block() {
struct NoInstrument {
account_id: AccountId,
pnl: Pnl,
fee: Fee,
}
impl HasInstrument for NoInstrument {
fn instrument(&self) -> Result<&Instrument, RequestFieldAccessError> {
Err(RequestFieldAccessError::new("instrument"))
}
}
impl HasAccountId for NoInstrument {
fn account_id(&self) -> Result<AccountId, RequestFieldAccessError> {
Ok(self.account_id)
}
}
impl HasPnl for NoInstrument {
fn pnl(&self) -> Result<Pnl, RequestFieldAccessError> {
Ok(self.pnl)
}
}
impl HasFee for NoInstrument {
fn fee(&self) -> Result<Fee, RequestFieldAccessError> {
Ok(self.fee)
}
}
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let report = NoInstrument {
account_id: account(1),
pnl: pnl("0"),
fee: Fee::ZERO,
};
let blocks = <TestPolicy as PreTradePolicy<
OrderOperation,
NoInstrument,
(),
crate::core::LocalSync,
>>::apply_execution_report(
&policy, &crate::pretrade::PostTradeContext::new(), &report
)
.map(|r| r.account_blocks)
.unwrap_or_default();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::MissingRequiredField);
assert_eq!(
blocks[0].reason,
"failed to access required field 'instrument'"
);
assert_eq!(blocks[0].details, "failed to access field 'instrument'");
}
#[test]
fn missing_account_id_in_report_returns_account_block() {
struct NoAccount {
instrument: Instrument,
pnl: Pnl,
fee: Fee,
}
impl HasInstrument for NoAccount {
fn instrument(&self) -> Result<&Instrument, RequestFieldAccessError> {
Ok(&self.instrument)
}
}
impl HasAccountId for NoAccount {
fn account_id(&self) -> Result<AccountId, RequestFieldAccessError> {
Err(RequestFieldAccessError::new("account_id"))
}
}
impl HasPnl for NoAccount {
fn pnl(&self) -> Result<Pnl, RequestFieldAccessError> {
Ok(self.pnl)
}
}
impl HasFee for NoAccount {
fn fee(&self) -> Result<Fee, RequestFieldAccessError> {
Ok(self.fee)
}
}
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let report = NoAccount {
instrument: Instrument::new(
Asset::new("AAPL").expect("must be valid"),
Asset::new("USD").expect("must be valid"),
),
pnl: pnl("0"),
fee: Fee::ZERO,
};
let blocks = <TestPolicy as PreTradePolicy<
OrderOperation,
NoAccount,
(),
crate::core::LocalSync,
>>::apply_execution_report(
&policy, &crate::pretrade::PostTradeContext::new(), &report
)
.map(|r| r.account_blocks)
.unwrap_or_default();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::MissingRequiredField);
assert_eq!(
blocks[0].reason,
"failed to access required field 'account ID'"
);
assert_eq!(blocks[0].details, "failed to access field 'account_id'");
}
#[test]
fn missing_pnl_in_report_returns_account_block() {
struct NoPnl {
instrument: Instrument,
account_id: AccountId,
fee: Fee,
}
impl HasInstrument for NoPnl {
fn instrument(&self) -> Result<&Instrument, RequestFieldAccessError> {
Ok(&self.instrument)
}
}
impl HasAccountId for NoPnl {
fn account_id(&self) -> Result<AccountId, RequestFieldAccessError> {
Ok(self.account_id)
}
}
impl HasPnl for NoPnl {
fn pnl(&self) -> Result<Pnl, RequestFieldAccessError> {
Err(RequestFieldAccessError::new("pnl"))
}
}
impl HasFee for NoPnl {
fn fee(&self) -> Result<Fee, RequestFieldAccessError> {
Ok(self.fee)
}
}
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let report = NoPnl {
instrument: Instrument::new(
Asset::new("AAPL").expect("must be valid"),
Asset::new("USD").expect("must be valid"),
),
account_id: account(1),
fee: Fee::ZERO,
};
let blocks = <TestPolicy as PreTradePolicy<
OrderOperation,
NoPnl,
(),
crate::core::LocalSync,
>>::apply_execution_report(
&policy, &crate::pretrade::PostTradeContext::new(), &report
)
.map(|r| r.account_blocks)
.unwrap_or_default();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::MissingRequiredField);
assert_eq!(blocks[0].reason, "failed to access required field 'P&L'");
assert_eq!(blocks[0].details, "failed to access field 'pnl'");
}
#[test]
fn missing_fee_in_report_returns_account_block() {
struct NoFee {
instrument: Instrument,
account_id: AccountId,
pnl: Pnl,
}
impl HasInstrument for NoFee {
fn instrument(&self) -> Result<&Instrument, RequestFieldAccessError> {
Ok(&self.instrument)
}
}
impl HasAccountId for NoFee {
fn account_id(&self) -> Result<AccountId, RequestFieldAccessError> {
Ok(self.account_id)
}
}
impl HasPnl for NoFee {
fn pnl(&self) -> Result<Pnl, RequestFieldAccessError> {
Ok(self.pnl)
}
}
impl HasFee for NoFee {
fn fee(&self) -> Result<Fee, RequestFieldAccessError> {
Err(RequestFieldAccessError::new("fee"))
}
}
let policy = policy_usd(Some(pnl("-100")), Some(pnl("50")));
let report = NoFee {
instrument: Instrument::new(
Asset::new("AAPL").expect("must be valid"),
Asset::new("USD").expect("must be valid"),
),
account_id: account(1),
pnl: pnl("0"),
};
let blocks = <TestPolicy as PreTradePolicy<
OrderOperation,
NoFee,
(),
crate::core::LocalSync,
>>::apply_execution_report(
&policy, &crate::pretrade::PostTradeContext::new(), &report
)
.map(|r| r.account_blocks)
.unwrap_or_default();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].code, RejectCode::MissingRequiredField);
assert_eq!(blocks[0].reason, "failed to access required field 'fee'");
assert_eq!(blocks[0].details, "failed to access field 'fee'");
}
#[test]
fn broker_barrier_excluding_zero_rejects_account_without_history() {
let settings =
PnlBoundsKillSwitchSettings::new([barrier("USD", Some(pnl("10")), None)], [])
.expect("settings must be valid");
let policy: TestPolicy =
PnlBoundsKillSwitchPolicy::new(settings, test_builder().storage_builder());
let reject = check_start(&policy, &order("USD", account(1))).expect_err("must reject");
let reject = &reject[0];
assert_eq!(reject.code, RejectCode::PnlKillSwitchTriggered);
assert_eq!(reject.reason, "pnl kill switch triggered: broker barrier");
assert!(reject.details.contains("lower bound breached"));
}
}