Add process event stream for backtests

This commit is contained in:
boris
2026-04-23 01:58:40 -07:00
parent e5fe1f0432
commit 23ba74909d
6 changed files with 384 additions and 11 deletions

View File

@@ -5,7 +5,10 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel;
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError;
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks;
use crate::strategy::{OrderIntent, StrategyDecision};
@@ -16,6 +19,7 @@ pub struct BrokerExecutionReport {
pub fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub process_events: Vec<ProcessEvent>,
pub diagnostics: Vec<String>,
}
@@ -440,6 +444,25 @@ where
order_id
}
fn emit_order_process_event(
report: &mut BrokerExecutionReport,
date: NaiveDate,
kind: ProcessEventKind,
order_id: u64,
symbol: &str,
side: OrderSide,
detail: impl Into<String>,
) {
report.process_events.push(ProcessEvent {
date,
kind,
order_id: Some(order_id),
symbol: Some(symbol.to_string()),
side: Some(side),
detail: detail.into(),
});
}
fn target_quantities(
&self,
date: NaiveDate,
@@ -881,6 +904,16 @@ where
return Ok(());
};
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingNew,
order_id,
symbol,
OrderSide::Sell,
format!("requested_quantity={requested_qty} reason={reason}"),
);
let rule = self.rules.can_sell(
date,
snapshot,
@@ -889,6 +922,7 @@ where
self.execution_price_field,
);
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
Some("paused")
| Some("sell disabled by eligibility flags")
@@ -903,11 +937,30 @@ where
requested_quantity: requested_qty,
filled_quantity: 0,
status,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
reason: format!("{reason}: {rule_reason}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!("status={status:?} reason={rule_reason}"),
);
return Ok(());
}
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderCreationPass,
order_id,
symbol,
OrderSide::Sell,
"sell order passed rule checks",
);
let sellable = position.sellable_qty(date);
let mut partial_fill_reason = if sellable < requested_qty {
Some("sellable quantity limit".to_string())
@@ -945,6 +998,18 @@ where
status: zero_fill_status_for_reason(&limit_reason),
reason: format!("{reason}: {limit_reason}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!(
"status={:?} reason={limit_reason}",
zero_fill_status_for_reason(&limit_reason)
),
);
return Ok(());
}
};
@@ -959,6 +1024,15 @@ where
status: OrderStatus::Rejected,
reason: format!("{reason}: no sellable quantity"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
"status=Rejected reason=no sellable quantity",
);
return Ok(());
}
@@ -1031,6 +1105,15 @@ where
net_cash_flow: net_cash,
reason: reason.to_string(),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::Trade,
order_id,
symbol,
OrderSide::Sell,
format!("filled_quantity={} price={}", leg.quantity, leg.price),
);
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
@@ -1097,6 +1180,17 @@ where
status,
reason: order_reason,
});
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Sell,
format!("status={status:?} filled_quantity={filled_qty}"),
);
}
Ok(())
}
@@ -1306,10 +1400,21 @@ where
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingNew,
order_id,
symbol,
OrderSide::Buy,
format!("requested_quantity={requested_qty} reason={reason}"),
);
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
Some("paused")
| Some("buy disabled by eligibility flags")
@@ -1324,11 +1429,30 @@ where
requested_quantity: requested_qty,
filled_quantity: 0,
status,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
reason: format!("{reason}: {rule_reason}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!("status={status:?} reason={rule_reason}"),
);
return Ok(());
}
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderCreationPass,
order_id,
symbol,
OrderSide::Buy,
"buy order passed rule checks",
);
let mut partial_fill_reason = None;
let market_limited_qty = self.market_fillable_quantity(
snapshot,
@@ -1357,6 +1481,18 @@ where
status: zero_fill_status_for_reason(&limit_reason),
reason: format!("{reason}: {limit_reason}"),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!(
"status={:?} reason={limit_reason}",
zero_fill_status_for_reason(&limit_reason)
),
);
return Ok(());
}
};
@@ -1432,6 +1568,20 @@ where
.unwrap_or("insufficient cash after fees")
),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!(
"status=Rejected reason={}",
partial_fill_reason
.as_deref()
.unwrap_or("insufficient cash after fees")
),
);
return Ok(());
}
@@ -1471,6 +1621,15 @@ where
net_cash_flow: -cash_out,
reason: reason.to_string(),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::Trade,
order_id,
symbol,
OrderSide::Buy,
format!("filled_quantity={} price={}", leg.quantity, leg.price),
);
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
@@ -1536,6 +1695,17 @@ where
status,
reason: order_reason,
});
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUnsolicitedUpdate,
order_id,
symbol,
OrderSide::Buy,
format!("status={status:?} filled_quantity={filled_qty}"),
);
}
Ok(())
}