Add futures exchange validators

This commit is contained in:
boris
2026-04-23 21:58:38 -07:00
parent 895aee1388
commit f056aa3468
5 changed files with 426 additions and 12 deletions
+130 -4
View File
@@ -48,6 +48,25 @@ pub struct BacktestConfig {
pub execution_price_field: PriceField,
}
#[derive(Debug, Clone, Copy)]
pub struct FuturesValidationConfig {
pub enforce_active_instrument: bool,
pub enforce_trading_phase: bool,
pub enforce_limit_price_tick: bool,
pub enforce_price_limits: bool,
}
impl Default for FuturesValidationConfig {
fn default() -> Self {
Self {
enforce_active_instrument: true,
enforce_trading_phase: true,
enforce_limit_price_tick: true,
enforce_price_limits: true,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DailyEquityPoint {
#[serde(with = "date_format")]
@@ -294,6 +313,7 @@ pub struct BacktestEngine<S, C, R> {
futures_expirations: BTreeMap<NaiveDate, BTreeMap<String, f64>>,
futures_settlement_price_mode: String,
futures_cost_model: FuturesTransactionCostModel,
futures_validation_config: FuturesValidationConfig,
}
impl<S, C, R> BacktestEngine<S, C, R> {
@@ -318,6 +338,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
futures_expirations: BTreeMap::new(),
futures_settlement_price_mode: "close".to_string(),
futures_cost_model: FuturesTransactionCostModel::default(),
futures_validation_config: FuturesValidationConfig::default(),
}
}
@@ -377,6 +398,11 @@ impl<S, C, R> BacktestEngine<S, C, R> {
self
}
pub fn with_futures_validation_config(mut self, config: FuturesValidationConfig) -> Self {
self.futures_validation_config = config;
self
}
pub fn process_event_bus_mut(&mut self) -> &mut ProcessEventBus {
&mut self.process_event_bus
}
@@ -832,12 +858,12 @@ where
);
};
if let Some(reason) = self.validate_futures_submission(&intent) {
let original_requested = intent.quantity;
let mut intent = self.resolve_futures_trading_parameters(date, intent);
if let Some(reason) = self.validate_futures_submission(date, &intent) {
return self.reject_futures_order(date, order_id, intent, reason);
}
let original_requested = intent.quantity;
let mut intent = self.resolve_futures_trading_parameters(date, intent);
let fill = self.resolve_futures_fill(date, &intent);
let Some((execution_price, fill_quantity)) = fill else {
if intent.allow_pending || intent.limit_price.is_some() {
@@ -1020,14 +1046,76 @@ where
report
}
fn validate_futures_submission(&self, intent: &FuturesOrderIntent) -> Option<String> {
fn validate_futures_submission(
&self,
date: NaiveDate,
intent: &FuturesOrderIntent,
) -> Option<String> {
if intent.quantity == 0 {
return Some("zero futures quantity".to_string());
}
if self.futures_validation_config.enforce_active_instrument {
if let Some(instrument) = self.data.instrument(&intent.symbol) {
if !instrument.is_active_on(date) {
return Some(format!(
"inactive futures instrument symbol={} date={date}",
intent.symbol
));
}
}
}
if self.futures_validation_config.enforce_trading_phase {
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
if snapshot.paused {
return Some(format!(
"paused futures instrument symbol={}",
intent.symbol
));
}
if !futures_trading_phase_allows_orders(snapshot.trading_phase.as_deref()) {
return Some(format!(
"futures trading phase does not allow orders symbol={} phase={}",
intent.symbol,
snapshot.trading_phase.as_deref().unwrap_or("")
));
}
}
}
if let Some(limit_price) = intent.limit_price {
if !limit_price.is_finite() || limit_price <= 0.0 {
return Some("invalid futures limit price".to_string());
}
if self.futures_validation_config.enforce_limit_price_tick {
let tick = self.futures_price_tick(date, &intent.symbol);
if !price_is_tick_aligned(limit_price, tick) {
return Some(format!(
"futures limit price not aligned to tick symbol={} price={limit_price:.6} tick={tick:.6}",
intent.symbol
));
}
}
if self.futures_validation_config.enforce_price_limits {
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
if snapshot.upper_limit.is_finite()
&& snapshot.upper_limit > 0.0
&& limit_price > snapshot.upper_limit + 1e-9
{
return Some(format!(
"futures limit price above upper limit symbol={} price={limit_price:.6} upper={:.6}",
intent.symbol, snapshot.upper_limit
));
}
if snapshot.lower_limit.is_finite()
&& snapshot.lower_limit > 0.0
&& limit_price < snapshot.lower_limit - 1e-9
{
return Some(format!(
"futures limit price below lower limit symbol={} price={limit_price:.6} lower={:.6}",
intent.symbol, snapshot.lower_limit
));
}
}
}
for order in &self.futures_open_orders {
if order.intent.symbol != intent.symbol || order.intent.side() == intent.side() {
continue;
@@ -1048,6 +1136,20 @@ where
None
}
fn futures_price_tick(&self, date: NaiveDate, symbol: &str) -> f64 {
self.data
.futures_trading_parameter(date, symbol)
.map(|params| params.price_tick)
.filter(|tick| tick.is_finite() && *tick > 0.0)
.or_else(|| {
self.data
.market(date, symbol)
.map(|snapshot| snapshot.effective_price_tick())
})
.unwrap_or(1.0)
.max(1e-9)
}
fn resolve_futures_trading_parameters(
&self,
date: NaiveDate,
@@ -3226,6 +3328,30 @@ fn analyzer_ratio_change(start: f64, end: f64) -> f64 {
}
}
fn price_is_tick_aligned(price: f64, tick: f64) -> bool {
if !price.is_finite() || !tick.is_finite() || tick <= 0.0 {
return false;
}
let ratio = price / tick;
(ratio - ratio.round()).abs() <= 1e-6
}
fn futures_trading_phase_allows_orders(phase: Option<&str>) -> bool {
let Some(phase) = phase.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
matches!(
phase.to_ascii_lowercase().as_str(),
"continuous"
| "trading"
| "trade"
| "open_auction"
| "auction"
| "call_auction"
| "opening_auction"
)
}
fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option<f64>) -> bool {
let Some(limit_price) = limit_price else {
return price.is_finite() && price > 0.0;