Files
fidc-backtest-engine/crates/fidc-core/src/broker.rs
2026-04-23 00:14:11 -07:00

1689 lines
55 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
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::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks;
use crate::strategy::{OrderIntent, StrategyDecision};
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
pub order_events: Vec<OrderEvent>,
pub fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
struct ExecutionFill {
price: f64,
quantity: u32,
next_cursor: NaiveDateTime,
}
#[derive(Debug, Clone)]
struct TargetConstraint {
symbol: String,
current_qty: u32,
desired_qty: u32,
min_target_qty: u32,
max_target_qty: u32,
provisional_target_qty: u32,
price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchingType {
OpenAuction,
CurrentBarClose,
NextBarOpen,
NextTickLast,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SlippageModel {
None,
PriceRatio(f64),
TickSize(f64),
}
pub struct BrokerSimulator<C, R> {
cost_model: C,
rules: R,
board_lot_size: u32,
execution_price_field: PriceField,
slippage_model: SlippageModel,
volume_percent: f64,
volume_limit: bool,
inactive_limit: bool,
liquidity_limit: bool,
intraday_execution_start_time: Option<NaiveTime>,
}
impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
execution_price_field: PriceField::Open,
slippage_model: SlippageModel::None,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
}
}
pub fn new_with_execution_price(
cost_model: C,
rules: R,
execution_price_field: PriceField,
) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
execution_price_field,
slippage_model: SlippageModel::None,
volume_percent: 0.25,
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
intraday_execution_start_time: None,
}
}
pub fn with_volume_limit(mut self, enabled: bool) -> Self {
self.volume_limit = enabled;
self
}
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
self.inactive_limit = enabled;
self
}
pub fn with_liquidity_limit(mut self, enabled: bool) -> Self {
self.liquidity_limit = enabled;
self
}
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
self.volume_percent = volume_percent;
self
}
pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self {
self.intraday_execution_start_time = Some(start_time);
self
}
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
self.slippage_model = slippage_model;
self
}
}
impl<C, R> BrokerSimulator<C, R>
where
C: CostModel,
R: EquityRuleHooks,
{
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.buy_price(self.execution_price_field)
}
fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.sell_price(self.execution_price_field)
}
fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.price(self.execution_price_field)
}
fn snapshot_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
let raw_price = if self.execution_price_field == PriceField::Last
&& self.intraday_execution_start_time.is_some()
{
let _ = side;
snapshot.price(PriceField::Last)
} else {
match side {
OrderSide::Buy => self.buy_price(snapshot),
OrderSide::Sell => self.sell_price(snapshot),
}
};
self.apply_slippage(snapshot, side, raw_price)
}
fn is_open_auction_matching(&self) -> bool {
self.execution_price_field == PriceField::DayOpen
}
fn apply_slippage(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
raw_price: f64,
) -> f64 {
if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price;
}
if self.is_open_auction_matching() {
return self.clamp_execution_price(snapshot, side, raw_price);
}
let adjusted = match self.slippage_model {
SlippageModel::None => raw_price,
SlippageModel::PriceRatio(ratio) => {
let ratio = ratio.max(0.0);
match side {
OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio),
}
}
SlippageModel::TickSize(ticks) => {
let tick = snapshot.effective_price_tick();
let ticks = ticks.max(0.0);
match side {
OrderSide::Buy => raw_price + tick * ticks,
OrderSide::Sell => raw_price - tick * ticks,
}
}
};
self.clamp_execution_price(snapshot, side, adjusted)
}
fn clamp_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
adjusted_price: f64,
) -> f64 {
if !adjusted_price.is_finite() {
return adjusted_price;
}
let mut bounded = adjusted_price.max(snapshot.effective_price_tick());
match side {
OrderSide::Buy => {
if snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 {
bounded = bounded.min(snapshot.upper_limit);
}
}
OrderSide::Sell => {
if snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 {
bounded = bounded.max(snapshot.lower_limit);
}
}
}
bounded
}
fn quote_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
raw_price: f64,
) -> f64 {
self.apply_slippage(snapshot, side, raw_price)
}
fn select_quote_reference_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
quote: &IntradayExecutionQuote,
side: OrderSide,
) -> Option<f64> {
let raw_price = match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}?;
let execution_price = self.quote_execution_price(snapshot, side, raw_price);
if execution_price.is_finite() && execution_price > 0.0 {
Some(execution_price)
} else {
None
}
}
pub fn execute(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
let mut intraday_turnover = BTreeMap::<String, u32>::new();
let mut execution_cursors = BTreeMap::<String, NaiveDateTime>::new();
let mut global_execution_cursor = None::<NaiveDateTime>;
let mut commission_state = BTreeMap::<u64, f64>::new();
let mut next_order_id = 1_u64;
if !decision.order_intents.is_empty() {
for intent in &decision.order_intents {
self.process_order_intent(
date,
portfolio,
data,
intent,
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
&mut next_order_id,
&mut report,
)?;
}
portfolio.prune_flat_positions();
return Ok(report);
}
let (target_quantities, rebalance_diagnostics) = if decision.rebalance {
self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else {
(BTreeMap::new(), Vec::new())
};
report.diagnostics.extend(rebalance_diagnostics);
let mut sell_symbols = BTreeSet::new();
sell_symbols.extend(portfolio.positions().keys().cloned());
sell_symbols.extend(decision.exit_symbols.iter().cloned());
sell_symbols.extend(target_quantities.keys().cloned());
for symbol in sell_symbols {
let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
if current_qty == 0 {
continue;
}
let target_qty = if decision.exit_symbols.contains(&symbol) {
0
} else if decision.rebalance {
*target_quantities.get(&symbol).unwrap_or(&0)
} else {
current_qty
};
if current_qty > target_qty {
let requested_qty = current_qty - target_qty;
self.process_sell(
date,
portfolio,
data,
&symbol,
requested_qty,
Self::reserve_order_id(&mut next_order_id),
sell_reason(decision, &symbol),
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
&mut report,
)?;
}
}
if decision.rebalance {
for (symbol, target_qty) in target_quantities {
let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
if target_qty > current_qty {
let requested_qty = target_qty - current_qty;
self.process_buy(
date,
portfolio,
data,
&symbol,
requested_qty,
Self::reserve_order_id(&mut next_order_id),
"rebalance_buy",
&mut intraday_turnover,
&mut execution_cursors,
&mut global_execution_cursor,
&mut commission_state,
None,
&mut report,
)?;
}
}
}
portfolio.prune_flat_positions();
Ok(report)
}
fn process_order_intent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
intent: &OrderIntent,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
next_order_id: &mut u64,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
match intent {
OrderIntent::TargetValue {
symbol,
target_value,
reason,
} => self.process_target_value(
date,
portfolio,
data,
symbol,
*target_value,
next_order_id,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::Value {
symbol,
value,
reason,
} => self.process_value(
date,
portfolio,
data,
symbol,
*value,
next_order_id,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
}
}
fn reserve_order_id(next_order_id: &mut u64) -> u64 {
let order_id = *next_order_id;
*next_order_id = next_order_id.saturating_add(1);
order_id
}
fn target_quantities(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
let equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?;
let target_weight_sum = target_weights.values().copied().sum::<f64>();
let mut desired_targets = BTreeMap::new();
let mut diagnostics = Vec::new();
for (symbol, weight) in target_weights {
let price = data
.price(date, symbol, self.execution_price_field)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.clone(),
field: price_field_name(self.execution_price_field),
})?;
let raw_qty = ((equity * weight) / price).floor() as u32;
desired_targets.insert(
symbol.clone(),
self.round_buy_quantity(
raw_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
),
);
}
let mut symbols = BTreeSet::new();
symbols.extend(portfolio.positions().keys().cloned());
symbols.extend(desired_targets.keys().cloned());
let mut constraints = Vec::new();
let mut projected_cash = portfolio.cash();
for symbol in symbols {
let current_qty = portfolio
.position(&symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let desired_qty = *desired_targets.get(&symbol).unwrap_or(&0);
let price = data
.price(date, &symbol, self.execution_price_field)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.clone(),
field: price_field_name(self.execution_price_field),
})?;
let minimum_order_quantity = self.minimum_order_quantity(data, &symbol);
let order_step_size = self.order_step_size(data, &symbol);
let min_target_qty = self.minimum_target_quantity(
date,
portfolio,
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
);
let max_target_qty = self.maximum_target_quantity(
date,
portfolio,
data,
&symbol,
current_qty,
minimum_order_quantity,
order_step_size,
);
let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty);
if provisional_target_qty != desired_qty && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_target_clipped symbol={} desired={} min={} max={} provisional={}",
symbol, desired_qty, min_target_qty, max_target_qty, provisional_target_qty
));
}
if current_qty > provisional_target_qty {
projected_cash += self.estimated_sell_net_cash(
date,
price,
current_qty.saturating_sub(provisional_target_qty),
);
}
constraints.push(TargetConstraint {
symbol: symbol.clone(),
current_qty,
desired_qty,
min_target_qty,
max_target_qty,
provisional_target_qty,
price,
minimum_order_quantity,
order_step_size,
});
}
let mut targets = BTreeMap::new();
for constraint in &constraints {
if constraint.provisional_target_qty > constraint.current_qty {
continue;
}
if constraint.provisional_target_qty > 0 {
targets.insert(constraint.symbol.clone(), constraint.provisional_target_qty);
}
}
let buy_constraints = constraints
.iter()
.filter(|constraint| constraint.provisional_target_qty > constraint.current_qty)
.collect::<Vec<_>>();
if buy_constraints.is_empty() {
return Ok((targets, diagnostics));
}
let mut best_targets = targets.clone();
let mut best_proportion_diff = f64::INFINITY;
let initial_safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 };
let mut safety = initial_safety;
loop {
let mut candidate_targets = targets.clone();
let mut buy_cash_out = 0.0;
for constraint in &buy_constraints {
let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32;
let mut target_qty = self
.round_buy_quantity(
scaled_desired_qty,
constraint.minimum_order_quantity,
constraint.order_step_size,
)
.clamp(constraint.min_target_qty, constraint.max_target_qty)
.max(constraint.current_qty);
if target_qty < constraint.current_qty {
target_qty = constraint.current_qty;
}
if target_qty > constraint.current_qty {
buy_cash_out += self.estimated_buy_cash_out(
date,
constraint.price,
target_qty - constraint.current_qty,
);
}
if target_qty > 0 {
candidate_targets.insert(constraint.symbol.clone(), target_qty);
}
}
let total_target_value = constraints
.iter()
.map(|constraint| {
candidate_targets
.get(&constraint.symbol)
.copied()
.unwrap_or(0) as f64
* constraint.price
})
.sum::<f64>();
let proportion_diff = if equity > 0.0 {
((total_target_value / equity) - target_weight_sum).abs()
} else {
0.0
};
if buy_cash_out <= projected_cash + 1e-6 {
if proportion_diff <= best_proportion_diff + 1e-12 {
best_targets = candidate_targets;
best_proportion_diff = proportion_diff;
} else if best_proportion_diff.is_finite() {
break;
}
}
if safety <= 0.0 {
break;
}
let step = (proportion_diff / 10.0).clamp(0.0001, 0.002);
let next_safety = (safety - step).max(0.0);
if (next_safety - safety).abs() < f64::EPSILON {
break;
}
safety = next_safety;
}
if safety < initial_safety && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_safety_scaled final_safety={:.4} target_weight_sum={:.4} projected_cash={:.2}",
safety, target_weight_sum, projected_cash
));
}
for constraint in &buy_constraints {
let final_target_qty = best_targets
.get(&constraint.symbol)
.copied()
.unwrap_or(constraint.current_qty);
if final_target_qty < constraint.provisional_target_qty && diagnostics.len() < 16 {
diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={}",
constraint.symbol,
constraint.provisional_target_qty,
final_target_qty,
constraint.current_qty
));
}
}
Ok((best_targets, diagnostics))
}
fn minimum_target_quantity(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
if current_qty == 0 {
return 0;
}
let Some(position) = portfolio.position(symbol) else {
return 0;
};
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty;
};
let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed {
return current_qty;
}
let sellable = position.sellable_qty(date);
let sell_limit = match self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
sellable.min(current_qty),
minimum_order_quantity,
order_step_size,
0,
sellable >= current_qty,
) {
Ok(quantity) => quantity.min(sellable).min(current_qty),
Err(_) => 0,
};
current_qty.saturating_sub(sell_limit)
}
fn maximum_target_quantity(
&self,
date: NaiveDate,
_portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
current_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let Ok(snapshot) = data.require_market(date, symbol) else {
return current_qty;
};
let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty;
};
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
return current_qty;
}
let additional_limit = match self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
u32::MAX,
minimum_order_quantity,
order_step_size,
0,
false,
) {
Ok(quantity) => quantity,
Err(_) => 0,
};
current_qty.saturating_add(additional_limit)
}
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(date, OrderSide::Sell, gross);
gross - cost.total()
}
fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
gross + cost.total()
}
fn process_sell(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
order_id: u64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let Some(position) = portfolio.position(symbol) else {
return Ok(());
};
let rule = self.rules.can_sell(
date,
snapshot,
candidate,
position,
self.execution_price_field,
);
if !rule.allowed {
let status = match rule.reason.as_deref() {
Some("paused")
| Some("sell disabled by eligibility flags")
| Some("open at or below lower limit") => OrderStatus::Canceled,
_ => OrderStatus::Rejected,
};
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
});
return Ok(());
}
let sellable = position.sellable_qty(date);
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Sell,
requested_qty.min(sellable),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
requested_qty >= position.quantity && sellable >= position.quantity,
);
let filled_qty = match market_limited_qty {
Ok(quantity) => quantity.min(sellable),
Err(limit_reason) => {
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {limit_reason}"),
});
return Ok(());
}
};
if filled_qty == 0 {
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: no sellable quantity"),
});
return Ok(());
}
let cash_before = portfolio.cash();
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Sell,
snapshot,
data,
filled_qty,
self.round_lot(data, symbol),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
filled_qty >= position.quantity,
execution_cursors,
None,
None,
None,
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
*global_execution_cursor = Some(fill.next_cursor);
}
(fill.quantity, fill.price)
} else {
(filled_qty, self.sell_price(snapshot))
};
let gross_amount = execution_price * filled_qty as f64;
let cost = self.cost_model.calculate_with_order_state(
date,
OrderSide::Sell,
gross_amount,
Some(order_id),
commission_state,
);
let net_cash = gross_amount - cost.total();
let realized_pnl = portfolio
.position_mut(symbol)
.sell(filled_qty, execution_price)
.map_err(BacktestError::Execution)?;
portfolio.apply_cash_delta(net_cash);
portfolio.prune_flat_positions();
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled
} else {
OrderStatus::Filled
};
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
status,
reason: reason.to_string(),
});
report.fill_events.push(FillEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Sell,
quantity: filled_qty,
price: execution_price,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
net_cash_flow: net_cash,
reason: reason.to_string(),
});
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
delta_quantity: -(filled_qty as i32),
quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: realized_pnl,
reason: reason.to_string(),
});
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: self.total_equity_at(
date,
portfolio,
data,
self.account_mark_price_field(),
)?,
note: format!("sell {symbol} {reason}"),
});
Ok(())
}
fn process_target_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_value: f64,
next_order_id: &mut u64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let price = data
.market(date, symbol)
.map(|snapshot| self.sizing_price(snapshot))
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let current_value = price * current_qty as f64;
let target_qty = self.round_buy_quantity(
((target_value.max(0.0)) / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
if current_qty > target_qty {
self.process_sell(
date,
portfolio,
data,
symbol,
current_qty - target_qty,
Self::reserve_order_id(next_order_id),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
} else if target_qty > current_qty {
self.process_buy(
date,
portfolio,
data,
symbol,
target_qty - current_qty,
Self::reserve_order_id(next_order_id),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
report,
)?;
} else if (current_value - target_value).abs() <= f64::EPSILON {
report.order_events.push(OrderEvent {
date,
order_id: None,
symbol: symbol.to_string(),
side: if current_qty > 0 {
OrderSide::Sell
} else {
OrderSide::Buy
},
requested_quantity: 0,
filled_quantity: 0,
status: OrderStatus::Filled,
reason: format!("{reason}: already at target value"),
});
}
Ok(())
}
fn process_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
value: f64,
next_order_id: &mut u64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if value.abs() <= f64::EPSILON {
return Ok(());
}
let snapshot = data
.market(date, symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
if value > 0.0 {
let round_lot = self.round_lot(data, symbol);
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
minimum_order_quantity,
order_step_size,
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
portfolio,
data,
symbol,
snapshot_requested_qty,
round_lot,
value.abs(),
reason,
execution_cursors,
*global_execution_cursor,
);
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
Self::reserve_order_id(next_order_id),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(value.abs()),
report,
)
} else {
let price = self.sizing_price(snapshot);
let requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
self.process_sell(
date,
portfolio,
data,
symbol,
requested_qty,
Self::reserve_order_id(next_order_id),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
}
fn maybe_expand_periodic_value_buy_quantity(
&self,
_date: NaiveDate,
_portfolio: &PortfolioState,
_data: &DataSet,
_symbol: &str,
requested_qty: u32,
_round_lot: u32,
_value_budget: f64,
_reason: &str,
_execution_cursors: &BTreeMap<String, NaiveDateTime>,
_global_execution_cursor: Option<NaiveDateTime>,
) -> u32 {
requested_qty
}
fn process_buy(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
order_id: u64,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
value_budget: Option<f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let rule = self
.rules
.can_buy(date, snapshot, candidate, self.execution_price_field);
if !rule.allowed {
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
});
return Ok(());
}
let market_limited_qty = self.market_fillable_quantity(
snapshot,
OrderSide::Buy,
requested_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
*intraday_turnover.get(symbol).unwrap_or(&0),
false,
);
let constrained_qty = match market_limited_qty {
Ok(quantity) => quantity,
Err(limit_reason) => {
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {limit_reason}"),
});
return Ok(());
}
};
let fill = self.resolve_execution_fill(
date,
symbol,
OrderSide::Buy,
snapshot,
data,
constrained_qty,
self.round_lot(data, symbol),
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
false,
execution_cursors,
None,
Some(portfolio.cash()),
value_budget.map(|budget| budget + 400.0),
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
*global_execution_cursor = Some(fill.next_cursor);
}
(fill.quantity, fill.price)
} else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
let filled_qty = self.affordable_buy_quantity(
date,
portfolio.cash(),
value_budget.map(|budget| budget + 400.0),
execution_price,
constrained_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
);
(filled_qty, execution_price)
};
if filled_qty == 0 {
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: insufficient cash after fees"),
});
return Ok(());
}
let cash_before = portfolio.cash();
let gross_amount = execution_price * filled_qty as f64;
let cost = self.cost_model.calculate_with_order_state(
date,
OrderSide::Buy,
gross_amount,
Some(order_id),
commission_state,
);
let cash_out = gross_amount + cost.total();
portfolio.apply_cash_delta(-cash_out);
portfolio
.position_mut(symbol)
.buy(date, filled_qty, execution_price);
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled
} else {
OrderStatus::Filled
};
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
status,
reason: reason.to_string(),
});
report.fill_events.push(FillEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side: OrderSide::Buy,
quantity: filled_qty,
price: execution_price,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
net_cash_flow: -cash_out,
reason: reason.to_string(),
});
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
delta_quantity: filled_qty as i32,
quantity_after: portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: 0.0,
reason: reason.to_string(),
});
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: self.total_equity_at(
date,
portfolio,
data,
self.account_mark_price_field(),
)?,
note: format!("buy {symbol} {reason}"),
});
Ok(())
}
fn total_equity_at(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
field: PriceField,
) -> Result<f64, BacktestError> {
let mut market_value = 0.0;
for position in portfolio.positions().values() {
let price = data.price(date, &position.symbol, field).ok_or_else(|| {
BacktestError::MissingPrice {
date,
symbol: position.symbol.clone(),
field: match field {
PriceField::DayOpen => "day_open",
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
},
}
})?;
market_value += price * position.quantity as f64;
}
Ok(portfolio.cash() + market_value)
}
fn round_lot(&self, data: &DataSet, symbol: &str) -> u32 {
data.instruments()
.get(symbol)
.map(|instrument| instrument.effective_round_lot())
.unwrap_or(self.board_lot_size.max(1))
}
fn minimum_order_quantity(&self, data: &DataSet, symbol: &str) -> u32 {
data.instruments()
.get(symbol)
.map(|instrument| instrument.minimum_order_quantity())
.unwrap_or(self.board_lot_size.max(1))
}
fn order_step_size(&self, data: &DataSet, symbol: &str) -> u32 {
data.instruments()
.get(symbol)
.map(|instrument| instrument.order_step_size())
.unwrap_or(self.board_lot_size.max(1))
}
fn account_mark_price_field(&self) -> PriceField {
if self.is_open_auction_matching() {
PriceField::DayOpen
} else {
PriceField::Open
}
}
fn round_buy_quantity(
&self,
quantity: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let step = order_step_size.max(1);
let normalized = (quantity / step) * step;
if normalized < minimum_order_quantity.max(1) {
0
} else {
normalized
}
}
fn decrement_order_quantity(
&self,
quantity: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let minimum = minimum_order_quantity.max(1);
if quantity <= minimum {
return 0;
}
let next = quantity.saturating_sub(order_step_size.max(1));
if next < minimum { 0 } else { next }
}
fn affordable_buy_quantity(
&self,
date: NaiveDate,
cash: f64,
gross_limit: Option<f64>,
price: f64,
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
let mut quantity =
self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size);
while quantity > 0 {
let gross = price * quantity as f64;
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
quantity = self.decrement_order_quantity(
quantity,
minimum_order_quantity,
order_step_size,
);
continue;
}
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
if gross + cost.total() <= cash + 1e-6 {
return quantity;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
0
}
fn market_fillable_quantity(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
consumed_turnover: u32,
allow_odd_lot_sell: bool,
) -> Result<u32, String> {
if requested_qty == 0 {
return Ok(0);
}
if self.inactive_limit && snapshot.tick_volume == 0 {
return Err("tick no volume".to_string());
}
let mut max_fill = requested_qty;
if self.liquidity_limit && !self.is_open_auction_matching() {
let top_level_liquidity = match side {
OrderSide::Buy => snapshot.liquidity_for_buy(),
OrderSide::Sell => snapshot.liquidity_for_sell(),
}
.min(u32::MAX as u64) as u32;
if top_level_liquidity == 0 {
return Err("no quote liquidity".to_string());
}
let top_level_limit = if side == OrderSide::Sell && allow_odd_lot_sell {
top_level_liquidity
} else {
self.round_buy_quantity(
top_level_liquidity,
minimum_order_quantity,
order_step_size,
)
};
max_fill = max_fill.min(top_level_limit);
}
if self.volume_limit {
let raw_limit = ((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
return Err("tick volume limit".to_string());
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit as u32
} else {
self.round_buy_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return Err("tick volume limit".to_string());
}
max_fill = max_fill.min(volume_limited);
}
Ok(max_fill)
}
fn resolve_execution_fill(
&self,
date: NaiveDate,
symbol: &str,
side: OrderSide,
snapshot: &crate::data::DailyMarketSnapshot,
data: &DataSet,
requested_qty: u32,
round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
_execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
_global_execution_cursor: Option<NaiveDateTime>,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
) -> Option<ExecutionFill> {
if self.execution_price_field != PriceField::Last {
return None;
}
if self.intraday_execution_start_time.is_some() {
let execution_price = self.snapshot_execution_price(snapshot, side);
let quantity = match side {
OrderSide::Buy => self.affordable_buy_quantity(
date,
cash_limit.unwrap_or(f64::INFINITY),
gross_limit,
execution_price,
requested_qty,
minimum_order_quantity,
order_step_size,
),
OrderSide::Sell => requested_qty,
};
if quantity == 0 {
return None;
}
let next_cursor = self
.intraday_execution_start_time
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill {
price: execution_price,
quantity,
next_cursor,
});
}
let start_cursor = self
.intraday_execution_start_time
.map(|start_time| date.and_time(start_time));
let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill(
snapshot,
quotes,
side,
start_cursor,
requested_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
cash_limit,
gross_limit,
) {
return Some(fill);
}
None
}
fn select_execution_fill(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
quotes: &[IntradayExecutionQuote],
side: OrderSide,
start_cursor: Option<NaiveDateTime>,
requested_qty: u32,
round_lot: u32,
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
) -> Option<ExecutionFill> {
if requested_qty == 0 {
return None;
}
let lot = round_lot.max(1);
let mut filled_qty = 0_u32;
let mut gross_amount = 0.0_f64;
let mut last_timestamp = None;
for quote in quotes {
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
continue;
}
// Approximate JoinQuant market-order fills with the evolving L1 book after
// the decision time instead of trade VWAP. This keeps quantities/prices
// closer to the observed 10:18 execution logs.
if quote.volume_delta == 0 {
continue;
}
let Some(quote_price) = self.select_quote_reference_price(snapshot, quote, side) else {
continue;
};
let top_level_liquidity = match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
};
let available_qty = top_level_liquidity
.saturating_mul(lot as u64)
.min(u32::MAX as u64) as u32;
if available_qty == 0 {
continue;
}
let remaining_qty = requested_qty.saturating_sub(filled_qty);
if remaining_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty);
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
take_qty =
self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size);
}
if take_qty == 0 {
continue;
}
if let Some(cash) = cash_limit {
while take_qty > 0 {
let candidate_gross = gross_amount + quote_price * take_qty as f64;
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
order_step_size,
);
continue;
}
if candidate_gross <= cash + 1e-6 {
break;
}
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
order_step_size,
);
}
if take_qty == 0 {
break;
}
}
gross_amount += quote_price * take_qty as f64;
filled_qty += take_qty;
last_timestamp = Some(quote.timestamp);
if filled_qty >= requested_qty {
break;
}
}
if filled_qty == 0 {
return None;
}
Some(ExecutionFill {
price: gross_amount / filled_qty as f64,
quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
})
}
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
let _ = reason;
false
}
}
fn price_field_name(field: PriceField) -> &'static str {
match field {
PriceField::DayOpen => "day_open",
PriceField::Open => "open",
PriceField::Close => "close",
PriceField::Last => "last",
}
}
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
if decision.exit_symbols.contains(symbol) {
"exit_hook_sell"
} else {
"rebalance_sell"
}
}