Add algo-order platform actions

This commit is contained in:
boris
2026-04-23 07:36:20 -07:00
parent 152b5c3141
commit ac308c8d68
7 changed files with 883 additions and 32 deletions

View File

@@ -12,7 +12,7 @@ use crate::events::{
};
use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks;
use crate::strategy::{OpenOrderView, OrderIntent, StrategyDecision};
use crate::strategy::{AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision};
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
@@ -73,6 +73,7 @@ pub enum MatchingType {
NextTickBestCounterparty,
CounterpartyOffer,
Vwap,
Twap,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -83,6 +84,19 @@ pub enum SlippageModel {
LimitPrice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AlgoExecutionStyle {
Vwap,
Twap,
}
#[derive(Debug, Clone, Copy)]
struct AlgoExecutionRequest {
style: AlgoExecutionStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
}
pub struct BrokerSimulator<C, R> {
cost_model: C,
rules: R,
@@ -306,13 +320,25 @@ where
self.apply_slippage(snapshot, side, raw_price)
}
fn matching_type_for_algo_request(
&self,
algo_request: Option<&AlgoExecutionRequest>,
) -> MatchingType {
match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
None => self.matching_type,
}
}
fn select_quote_reference_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
quote: &IntradayExecutionQuote,
side: OrderSide,
matching_type: MatchingType,
) -> Option<f64> {
let raw_price = match self.matching_type {
let raw_price = match matching_type {
MatchingType::NextTickBestOwn => match side {
OrderSide::Buy => {
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
@@ -343,7 +369,7 @@ where
OrderSide::Sell => quote.sell_price(),
}
}
MatchingType::NextTickLast | MatchingType::Vwap => {
MatchingType::NextTickLast | MatchingType::Vwap | MatchingType::Twap => {
if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
@@ -452,6 +478,7 @@ where
None,
false,
true,
None,
&mut report,
)?;
}
@@ -481,6 +508,7 @@ where
None,
false,
true,
None,
&mut report,
)?;
}
@@ -756,6 +784,52 @@ where
commission_state,
report,
),
OrderIntent::AlgoValue {
symbol,
value,
style,
start_time,
end_time,
reason,
} => self.process_algo_value(
date,
portfolio,
data,
symbol,
*value,
*style,
*start_time,
*end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::AlgoPercent {
symbol,
percent,
style,
start_time,
end_time,
reason,
} => self.process_algo_percent(
date,
portfolio,
data,
symbol,
*percent,
*style,
*start_time,
*end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::TargetPortfolioSmart {
target_weights,
order_prices,
@@ -893,6 +967,7 @@ where
Some(limit_price),
true,
emit_creation_events,
None,
report,
)
} else {
@@ -911,6 +986,7 @@ where
Some(limit_price),
true,
emit_creation_events,
None,
report,
)
}
@@ -1817,6 +1893,7 @@ where
limit_price: Option<f64>,
allow_pending_limit: bool,
emit_creation_events: bool,
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
@@ -2046,6 +2123,7 @@ where
None,
None,
None,
algo_request,
limit_price,
);
let (filled_qty, execution_legs) = if let Some(fill) = fill {
@@ -2348,6 +2426,7 @@ where
None,
false,
true,
None,
report,
)?;
} else if target_qty > current_qty {
@@ -2367,6 +2446,7 @@ where
None,
false,
true,
None,
report,
)?;
} else if (current_value - target_value).abs() <= f64::EPSILON {
@@ -2435,6 +2515,7 @@ where
None,
false,
true,
None,
report,
)?;
}
@@ -2461,6 +2542,7 @@ where
None,
false,
true,
None,
report,
)?;
}
@@ -2533,6 +2615,7 @@ where
Some(limit_price),
true,
true,
None,
report,
)?;
} else if target_qty > current_qty {
@@ -2552,6 +2635,7 @@ where
Some(limit_price),
true,
true,
None,
report,
)?;
}
@@ -2606,6 +2690,7 @@ where
Some(limit_price),
true,
true,
None,
report,
)?;
}
@@ -2632,6 +2717,7 @@ where
Some(limit_price),
true,
true,
None,
report,
)?;
}
@@ -2764,6 +2850,7 @@ where
None,
false,
true,
None,
report,
)
} else {
@@ -2788,6 +2875,7 @@ where
None,
false,
true,
None,
report,
)
}
@@ -2856,6 +2944,7 @@ where
Some(limit_price),
true,
true,
None,
report,
)
} else {
@@ -2880,6 +2969,7 @@ where
Some(limit_price),
true,
true,
None,
report,
)
}
@@ -2947,6 +3037,146 @@ where
)
}
fn process_algo_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
value: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
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),
})?;
let algo_request = AlgoExecutionRequest {
style: match style {
AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap,
AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap,
},
start_time,
end_time,
};
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(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
Some(value.abs()),
None,
false,
true,
Some(&algo_request),
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(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
Some(&algo_request),
report,
)
}
}
fn process_algo_percent(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
percent: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
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 total_equity = self.rebalance_total_equity_at(date, portfolio, data)?;
self.process_algo_value(
date,
portfolio,
data,
symbol,
total_equity * percent,
style,
start_time,
end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
}
fn process_shares(
&self,
date: NaiveDate,
@@ -2986,6 +3216,7 @@ where
None,
false,
true,
None,
report,
)
} else {
@@ -3004,6 +3235,7 @@ where
None,
false,
true,
None,
report,
)
}
@@ -3078,6 +3310,7 @@ where
limit_price: Option<f64>,
allow_pending_limit: bool,
emit_creation_events: bool,
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
@@ -3232,6 +3465,7 @@ where
None,
Some(portfolio.cash()),
value_budget.map(|budget| budget + 400.0),
algo_request,
limit_price,
);
let (filled_qty, execution_legs) = if let Some(fill) = fill {
@@ -3858,22 +4092,32 @@ where
_global_execution_cursor: Option<NaiveDateTime>,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
algo_request: Option<&AlgoExecutionRequest>,
limit_price: Option<f64>,
) -> Option<ExecutionFill> {
if self.execution_price_field != PriceField::Last {
let matching_type = self.matching_type_for_algo_request(algo_request);
let use_intraday_quotes =
algo_request.is_some() || self.execution_price_field == PriceField::Last;
if !use_intraday_quotes {
return None;
}
let start_cursor = self
.intraday_execution_start_time
let start_cursor = algo_request
.and_then(|request| request.start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time));
let end_cursor = algo_request
.and_then(|request| request.end_time)
.map(|end_time| date.and_time(end_time));
let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill(
snapshot,
quotes,
side,
matching_type,
start_cursor,
end_cursor,
requested_qty,
round_lot,
minimum_order_quantity,
@@ -3886,7 +4130,7 @@ where
return Some(fill);
}
if self.intraday_execution_start_time.is_some() {
if algo_request.is_some() || self.intraday_execution_start_time.is_some() {
let execution_price = self.snapshot_execution_price(snapshot, side);
if !self.price_satisfies_limit(
side,
@@ -3913,8 +4157,9 @@ where
if quantity == 0 {
return None;
}
let next_cursor = self
.intraday_execution_start_time
let next_cursor = algo_request
.and_then(|request| request.start_time)
.or(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 {
@@ -3942,7 +4187,9 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
quotes: &[IntradayExecutionQuote],
side: OrderSide,
matching_type: MatchingType,
start_cursor: Option<NaiveDateTime>,
end_cursor: Option<NaiveDateTime>,
requested_qty: u32,
round_lot: u32,
minimum_order_quantity: u32,
@@ -3957,26 +4204,28 @@ where
}
let lot = round_lot.max(1);
let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes
.iter()
.filter(|quote| {
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
&& quote.volume_delta != 0
})
.collect();
let mut filled_qty = 0_u32;
let mut gross_amount = 0.0_f64;
let mut last_timestamp = None;
let mut legs = Vec::new();
let mut budget_block_reason = None;
let mut saw_quote_after_cursor = false;
for quote in quotes {
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
continue;
}
saw_quote_after_cursor = true;
let saw_quote_after_cursor = !eligible_quotes.is_empty();
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
// 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 {
let Some(quote_price) =
self.select_quote_reference_price(snapshot, quote, side, matching_type)
else {
continue;
};
if !self.price_satisfies_limit(
@@ -4003,7 +4252,14 @@ where
if remaining_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty);
let mut take_qty = if matching_type == MatchingType::Twap {
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
let scheduled_qty =
((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32;
remaining_qty.min(available_qty).min(scheduled_qty.max(1))
} else {
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);
@@ -4059,7 +4315,7 @@ where
Some(ExecutionFill {
quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if self.matching_type == MatchingType::Vwap {
legs: if matching_type == MatchingType::Vwap {
vec![ExecutionLeg {
price: gross_amount / filled_qty as f64,
quantity: filled_qty,