Add algo-order platform actions
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user