use crate::internal::domain::{AccountMode, AssetClass, Money, Quantity};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RiskPolicy {
pub policy_id: String,
pub enabled: bool,
pub allowed_account_modes: Vec<AccountMode>,
pub allowed_asset_classes: Vec<AssetClass>,
pub max_notional: Option<Money>,
pub max_quantity: Option<Quantity>,
pub max_order_count_per_window: Option<u32>,
pub allow_fractional: bool,
pub requires_market_data_freshness: bool,
pub requires_approval_for_preview: bool,
}
impl Default for RiskPolicy {
fn default() -> Self {
Self {
policy_id: "default-preview-disabled".to_string(),
enabled: false,
allowed_account_modes: vec![AccountMode::Paper],
allowed_asset_classes: vec![AssetClass::Stock, AssetClass::Etf],
max_notional: None,
max_quantity: None,
max_order_count_per_window: None,
allow_fractional: false,
requires_market_data_freshness: true,
requires_approval_for_preview: false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RiskWarning {
pub code: String,
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RiskRefusal {
pub code: String,
pub message: String,
pub user_action: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RiskDecision {
Allow {
warnings: Vec<RiskWarning>,
},
Refuse {
refusals: Vec<RiskRefusal>,
},
}
#[cfg(test)]
mod tests {
use super::super::run_risk_checks;
use super::RiskPolicy;
use crate::internal::domain::{
AccountId, AccountMode, AssetClass, CurrencyCode, LocalUserId, Money, OrderContractInput,
OrderIntent, OrderIntentId, OrderSide, PreviewOrderType, Quantity, TimeInForce,
};
use rust_decimal::Decimal;
use time::OffsetDateTime;
#[test]
fn default_policy_refuses_preview() {
let policy = RiskPolicy::default();
let decision = run_risk_checks(&intent(), &policy);
assert!(matches!(decision, super::RiskDecision::Refuse { .. }));
}
#[test]
fn enabled_policy_allows_basic_limit_stock() {
let policy = RiskPolicy {
enabled: true,
..RiskPolicy::default()
};
let decision = run_risk_checks(&intent(), &policy);
assert!(matches!(decision, super::RiskDecision::Allow { .. }));
}
fn intent() -> OrderIntent {
let Some(account_id) = AccountId::new("DU1234567") else {
unreachable!("static account id should be valid");
};
let Some(currency) = CurrencyCode::new("USD") else {
unreachable!("static currency should be valid");
};
OrderIntent {
intent_id: OrderIntentId::new(),
account_id,
account_mode: AccountMode::Paper,
contract: OrderContractInput::Query {
symbol: "AAPL".to_string(),
asset_class: AssetClass::Stock,
currency: currency.clone(),
exchange: Some("SMART".to_string()),
},
side: OrderSide::Buy,
quantity: Quantity::new(Decimal::ONE),
order_type: PreviewOrderType::Limit,
limit_price: Some(Money {
amount: Decimal::new(100, 0),
currency,
}),
stop_price: None,
trailing_amount: None,
trailing_percent: None,
time_in_force: TimeInForce::Day,
rationale: None,
created_by: LocalUserId::from_static("local-user"),
created_at: OffsetDateTime::UNIX_EPOCH,
}
}
}