4889 lines
164 KiB
Rust
4889 lines
164 KiB
Rust
use std::cell::{Cell, RefCell};
|
|
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, ProcessEvent,
|
|
ProcessEventKind,
|
|
};
|
|
use crate::portfolio::PortfolioState;
|
|
use crate::rules::EquityRuleHooks;
|
|
use crate::strategy::{
|
|
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
|
|
};
|
|
|
|
#[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 process_events: Vec<ProcessEvent>,
|
|
pub diagnostics: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct ExecutionLeg {
|
|
price: f64,
|
|
quantity: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ExecutionFill {
|
|
quantity: u32,
|
|
next_cursor: NaiveDateTime,
|
|
legs: Vec<ExecutionLeg>,
|
|
unfilled_reason: Option<&'static str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct OpenOrder {
|
|
order_id: u64,
|
|
symbol: String,
|
|
side: OrderSide,
|
|
requested_quantity: u32,
|
|
filled_quantity: u32,
|
|
remaining_quantity: u32,
|
|
limit_price: f64,
|
|
reason: String,
|
|
}
|
|
|
|
#[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,
|
|
NextTickBestOwn,
|
|
NextTickBestCounterparty,
|
|
CounterpartyOffer,
|
|
Vwap,
|
|
Twap,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum SlippageModel {
|
|
None,
|
|
PriceRatio(f64),
|
|
TickSize(f64),
|
|
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,
|
|
board_lot_size: u32,
|
|
matching_type: MatchingType,
|
|
execution_price_field: PriceField,
|
|
slippage_model: SlippageModel,
|
|
volume_percent: f64,
|
|
volume_limit: bool,
|
|
inactive_limit: bool,
|
|
liquidity_limit: bool,
|
|
strict_value_budget: bool,
|
|
same_day_buy_close_mark_at_fill: bool,
|
|
intraday_execution_start_time: Option<NaiveTime>,
|
|
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
|
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
|
next_order_id: Cell<u64>,
|
|
open_orders: RefCell<Vec<OpenOrder>>,
|
|
}
|
|
|
|
impl<C, R> BrokerSimulator<C, R> {
|
|
pub fn new(cost_model: C, rules: R) -> Self {
|
|
Self {
|
|
cost_model,
|
|
rules,
|
|
board_lot_size: 100,
|
|
matching_type: matching_type_from_price_field(PriceField::Open),
|
|
execution_price_field: PriceField::Open,
|
|
slippage_model: SlippageModel::None,
|
|
volume_percent: 0.25,
|
|
volume_limit: true,
|
|
inactive_limit: true,
|
|
liquidity_limit: true,
|
|
strict_value_budget: false,
|
|
same_day_buy_close_mark_at_fill: false,
|
|
intraday_execution_start_time: None,
|
|
runtime_intraday_start_time: Cell::new(None),
|
|
runtime_intraday_end_time: Cell::new(None),
|
|
next_order_id: Cell::new(1),
|
|
open_orders: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
pub fn new_with_execution_price(
|
|
cost_model: C,
|
|
rules: R,
|
|
execution_price_field: PriceField,
|
|
) -> Self {
|
|
Self {
|
|
cost_model,
|
|
rules,
|
|
board_lot_size: 100,
|
|
matching_type: matching_type_from_price_field(execution_price_field),
|
|
execution_price_field,
|
|
slippage_model: SlippageModel::None,
|
|
volume_percent: 0.25,
|
|
volume_limit: true,
|
|
inactive_limit: true,
|
|
liquidity_limit: true,
|
|
strict_value_budget: false,
|
|
same_day_buy_close_mark_at_fill: false,
|
|
intraday_execution_start_time: None,
|
|
runtime_intraday_start_time: Cell::new(None),
|
|
runtime_intraday_end_time: Cell::new(None),
|
|
next_order_id: Cell::new(1),
|
|
open_orders: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
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_strict_value_budget(mut self, enabled: bool) -> Self {
|
|
self.strict_value_budget = enabled;
|
|
self
|
|
}
|
|
|
|
pub fn with_same_day_buy_close_mark_at_fill(mut self, enabled: bool) -> Self {
|
|
self.same_day_buy_close_mark_at_fill = enabled;
|
|
self
|
|
}
|
|
|
|
pub fn same_day_buy_close_mark_at_fill(&self) -> bool {
|
|
self.same_day_buy_close_mark_at_fill
|
|
}
|
|
|
|
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_matching_type(mut self, matching_type: MatchingType) -> Self {
|
|
self.matching_type = matching_type;
|
|
self
|
|
}
|
|
|
|
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
|
|
self.slippage_model = slippage_model;
|
|
self
|
|
}
|
|
|
|
pub fn matching_type(&self) -> MatchingType {
|
|
self.matching_type
|
|
}
|
|
|
|
pub fn execution_price_field(&self) -> PriceField {
|
|
self.execution_price_field
|
|
}
|
|
|
|
pub fn open_order_views(&self) -> Vec<OpenOrderView> {
|
|
self.open_orders
|
|
.borrow()
|
|
.iter()
|
|
.map(|order| OpenOrderView {
|
|
order_id: order.order_id,
|
|
symbol: order.symbol.clone(),
|
|
side: order.side,
|
|
requested_quantity: order.requested_quantity,
|
|
filled_quantity: order.filled_quantity,
|
|
remaining_quantity: order.remaining_quantity,
|
|
unfilled_quantity: order.remaining_quantity,
|
|
status: OrderStatus::Pending,
|
|
avg_price: 0.0,
|
|
transaction_cost: 0.0,
|
|
limit_price: order.limit_price,
|
|
reason: order.reason.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
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 value_buy_sizing_price(
|
|
&self,
|
|
date: NaiveDate,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
snapshot: &crate::data::DailyMarketSnapshot,
|
|
) -> f64 {
|
|
let start_cursor = self
|
|
.runtime_intraday_start_time
|
|
.get()
|
|
.or(self.intraday_execution_start_time)
|
|
.map(|start_time| date.and_time(start_time));
|
|
data.execution_quotes_on(date, symbol)
|
|
.iter()
|
|
.filter(|quote| {
|
|
start_cursor
|
|
.map(|cursor| quote.timestamp >= cursor)
|
|
.unwrap_or(true)
|
|
})
|
|
.next()
|
|
.and_then(|quote| match self.execution_price_field {
|
|
PriceField::Last => (quote.last_price.is_finite() && quote.last_price > 0.0)
|
|
.then_some(quote.last_price),
|
|
_ => quote.buy_price(),
|
|
})
|
|
.unwrap_or_else(|| self.sizing_price(snapshot))
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
SlippageModel::LimitPrice => raw_price,
|
|
};
|
|
|
|
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 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 matching_type {
|
|
MatchingType::NextTickBestOwn => match side {
|
|
OrderSide::Buy => {
|
|
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
|
|
Some(quote.bid1)
|
|
} else {
|
|
quote
|
|
.last_price
|
|
.is_finite()
|
|
.then_some(quote.last_price)
|
|
.filter(|price| *price > 0.0)
|
|
}
|
|
}
|
|
OrderSide::Sell => {
|
|
if quote.ask1.is_finite() && quote.ask1 > 0.0 {
|
|
Some(quote.ask1)
|
|
} else {
|
|
quote
|
|
.last_price
|
|
.is_finite()
|
|
.then_some(quote.last_price)
|
|
.filter(|price| *price > 0.0)
|
|
}
|
|
}
|
|
},
|
|
MatchingType::NextTickBestCounterparty | MatchingType::CounterpartyOffer => {
|
|
match side {
|
|
OrderSide::Buy => quote.buy_price(),
|
|
OrderSide::Sell => quote.sell_price(),
|
|
}
|
|
}
|
|
MatchingType::NextTickLast | MatchingType::Vwap | MatchingType::Twap => {
|
|
if quote.last_price.is_finite() && quote.last_price > 0.0 {
|
|
Some(quote.last_price)
|
|
} else {
|
|
match side {
|
|
OrderSide::Buy => quote.buy_price(),
|
|
OrderSide::Sell => quote.sell_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();
|
|
self.process_open_orders(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
&mut intraday_turnover,
|
|
&mut execution_cursors,
|
|
&mut global_execution_cursor,
|
|
&mut commission_state,
|
|
&mut report,
|
|
)?;
|
|
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 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(),
|
|
sell_reason(decision, &symbol),
|
|
&mut intraday_turnover,
|
|
&mut execution_cursors,
|
|
&mut global_execution_cursor,
|
|
&mut commission_state,
|
|
None,
|
|
false,
|
|
true,
|
|
None,
|
|
&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(),
|
|
"rebalance_buy",
|
|
&mut intraday_turnover,
|
|
&mut execution_cursors,
|
|
&mut global_execution_cursor,
|
|
&mut commission_state,
|
|
None,
|
|
None,
|
|
false,
|
|
true,
|
|
None,
|
|
&mut report,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
portfolio.prune_flat_positions();
|
|
Ok(report)
|
|
}
|
|
|
|
pub fn execute_between(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
decision: &StrategyDecision,
|
|
start_time: Option<NaiveTime>,
|
|
end_time: Option<NaiveTime>,
|
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
|
let previous_start_time = self.runtime_intraday_start_time.get();
|
|
let previous_end_time = self.runtime_intraday_end_time.get();
|
|
self.runtime_intraday_start_time.set(start_time);
|
|
self.runtime_intraday_end_time.set(end_time);
|
|
let result = self.execute(date, portfolio, data, decision);
|
|
self.runtime_intraday_start_time.set(previous_start_time);
|
|
self.runtime_intraday_end_time.set(previous_end_time);
|
|
result
|
|
}
|
|
|
|
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>,
|
|
report: &mut BrokerExecutionReport,
|
|
) -> Result<(), BacktestError> {
|
|
match intent {
|
|
OrderIntent::Shares {
|
|
symbol,
|
|
quantity,
|
|
reason,
|
|
} => self.process_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*quantity,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
report,
|
|
),
|
|
OrderIntent::LimitShares {
|
|
symbol,
|
|
quantity,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*quantity,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::Lots {
|
|
symbol,
|
|
lots,
|
|
reason,
|
|
} => self.process_lots(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*lots,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::LimitLots {
|
|
symbol,
|
|
lots,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_lots(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*lots,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::TargetShares {
|
|
symbol,
|
|
target_quantity,
|
|
reason,
|
|
} => self.process_target_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*target_quantity,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::LimitTargetShares {
|
|
symbol,
|
|
target_quantity,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_target_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*target_quantity,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::TargetValue {
|
|
symbol,
|
|
target_value,
|
|
reason,
|
|
} => self.process_target_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*target_value,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::LimitTargetValue {
|
|
symbol,
|
|
target_value,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_target_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*target_value,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::Value {
|
|
symbol,
|
|
value,
|
|
reason,
|
|
} => self.process_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*value,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::LimitValue {
|
|
symbol,
|
|
value,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*value,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::Percent {
|
|
symbol,
|
|
percent,
|
|
reason,
|
|
} => self.process_percent(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*percent,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::LimitPercent {
|
|
symbol,
|
|
percent,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_percent(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*percent,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::TargetPercent {
|
|
symbol,
|
|
target_percent,
|
|
reason,
|
|
} => self.process_target_percent(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*target_percent,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::LimitTargetPercent {
|
|
symbol,
|
|
target_percent,
|
|
limit_price,
|
|
reason,
|
|
} => self.process_limit_target_percent(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
*target_percent,
|
|
*limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
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,
|
|
valuation_prices,
|
|
reason,
|
|
} => self.process_target_portfolio_smart(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
target_weights,
|
|
order_prices.as_ref(),
|
|
valuation_prices.as_ref(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
),
|
|
OrderIntent::CancelOrder { order_id, reason } => {
|
|
self.cancel_open_order(date, *order_id, reason, report);
|
|
Ok(())
|
|
}
|
|
OrderIntent::CancelSymbol { symbol, reason } => {
|
|
self.cancel_open_orders_for_symbol(date, symbol, reason, report);
|
|
Ok(())
|
|
}
|
|
OrderIntent::CancelAll { reason } => {
|
|
self.cancel_all_open_orders(date, reason, report);
|
|
Ok(())
|
|
}
|
|
OrderIntent::UpdateUniverse { symbols, reason } => {
|
|
report.diagnostics.push(format!(
|
|
"engine_control_intent_skipped kind=update_universe count={} reason={}",
|
|
symbols.len(),
|
|
reason
|
|
));
|
|
Ok(())
|
|
}
|
|
OrderIntent::Subscribe { symbols, reason } => {
|
|
report.diagnostics.push(format!(
|
|
"engine_control_intent_skipped kind=subscribe count={} reason={}",
|
|
symbols.len(),
|
|
reason
|
|
));
|
|
Ok(())
|
|
}
|
|
OrderIntent::Unsubscribe { symbols, reason } => {
|
|
report.diagnostics.push(format!(
|
|
"engine_control_intent_skipped kind=unsubscribe count={} reason={}",
|
|
symbols.len(),
|
|
reason
|
|
));
|
|
Ok(())
|
|
}
|
|
OrderIntent::DepositWithdraw {
|
|
amount,
|
|
receiving_days,
|
|
reason,
|
|
} => {
|
|
report.diagnostics.push(format!(
|
|
"engine_account_intent_skipped kind=deposit_withdraw amount={amount:.2} receiving_days={receiving_days} reason={reason}"
|
|
));
|
|
Ok(())
|
|
}
|
|
OrderIntent::FinanceRepay { amount, reason } => {
|
|
report.diagnostics.push(format!(
|
|
"engine_account_intent_skipped kind=finance_repay amount={amount:.2} reason={reason}"
|
|
));
|
|
Ok(())
|
|
}
|
|
OrderIntent::SetManagementFeeRate { rate, reason } => {
|
|
report.diagnostics.push(format!(
|
|
"engine_account_intent_skipped kind=set_management_fee_rate rate={rate:.6} reason={reason}"
|
|
));
|
|
Ok(())
|
|
}
|
|
OrderIntent::Futures { intent } => {
|
|
report.diagnostics.push(format!(
|
|
"engine_futures_intent_skipped symbol={} direction={} effect={} reason={}",
|
|
intent.symbol,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str(),
|
|
intent.reason
|
|
));
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_limit_shares(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
quantity: i32,
|
|
limit_price: f64,
|
|
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> {
|
|
self.process_limit_shares_internal(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
quantity,
|
|
limit_price,
|
|
reason,
|
|
None,
|
|
true,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)
|
|
}
|
|
|
|
fn process_limit_shares_internal(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
quantity: i32,
|
|
limit_price: f64,
|
|
reason: &str,
|
|
existing_order_id: Option<u64>,
|
|
emit_creation_events: bool,
|
|
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 quantity == 0 {
|
|
return Ok(());
|
|
}
|
|
let order_id = existing_order_id.unwrap_or_else(|| self.reserve_order_id());
|
|
if quantity > 0 {
|
|
let requested_qty = self.round_buy_quantity(
|
|
quantity as u32,
|
|
self.minimum_order_quantity(data, symbol),
|
|
self.order_step_size(data, symbol),
|
|
);
|
|
if requested_qty == 0 {
|
|
return Ok(());
|
|
}
|
|
self.process_buy(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
requested_qty,
|
|
order_id,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
Some(limit_price),
|
|
true,
|
|
emit_creation_events,
|
|
None,
|
|
report,
|
|
)
|
|
} else {
|
|
self.process_sell(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
quantity.unsigned_abs(),
|
|
order_id,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
Some(limit_price),
|
|
true,
|
|
emit_creation_events,
|
|
None,
|
|
report,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn process_limit_lots(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
lots: i32,
|
|
limit_price: f64,
|
|
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 round_lot = self.round_lot(data, symbol);
|
|
let requested_quantity = lots.saturating_abs() as u32 * round_lot;
|
|
let signed_quantity = if lots >= 0 {
|
|
requested_quantity as i32
|
|
} else {
|
|
-(requested_quantity as i32)
|
|
};
|
|
self.process_limit_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
signed_quantity,
|
|
limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)
|
|
}
|
|
|
|
fn reserve_order_id(&self) -> u64 {
|
|
let order_id = self.next_order_id.get();
|
|
self.next_order_id.set(order_id.saturating_add(1));
|
|
order_id
|
|
}
|
|
|
|
fn upsert_open_order(&self, open_order: OpenOrder) {
|
|
let mut open_orders = self.open_orders.borrow_mut();
|
|
open_orders.retain(|existing| existing.order_id != open_order.order_id);
|
|
open_orders.push(open_order);
|
|
}
|
|
|
|
fn clear_open_order(&self, order_id: u64) {
|
|
self.open_orders
|
|
.borrow_mut()
|
|
.retain(|existing| existing.order_id != order_id);
|
|
}
|
|
|
|
fn extend_report(into: &mut BrokerExecutionReport, mut other: BrokerExecutionReport) {
|
|
into.order_events.append(&mut other.order_events);
|
|
into.fill_events.append(&mut other.fill_events);
|
|
into.position_events.append(&mut other.position_events);
|
|
into.account_events.append(&mut other.account_events);
|
|
into.process_events.append(&mut other.process_events);
|
|
into.diagnostics.append(&mut other.diagnostics);
|
|
}
|
|
|
|
fn reserved_open_sell_quantity(&self, symbol: &str, exclude_order_id: Option<u64>) -> u32 {
|
|
self.open_orders
|
|
.borrow()
|
|
.iter()
|
|
.filter(|order| {
|
|
order.side == OrderSide::Sell
|
|
&& order.symbol == symbol
|
|
&& exclude_order_id.is_none_or(|order_id| order.order_id != order_id)
|
|
})
|
|
.map(|order| order.remaining_quantity)
|
|
.sum()
|
|
}
|
|
|
|
fn process_open_orders(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
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 pending_orders = {
|
|
let mut open_orders = self.open_orders.borrow_mut();
|
|
std::mem::take(&mut *open_orders)
|
|
};
|
|
for order in pending_orders {
|
|
let signed_quantity = if order.side == OrderSide::Buy {
|
|
order.remaining_quantity as i32
|
|
} else {
|
|
-(order.remaining_quantity as i32)
|
|
};
|
|
self.process_limit_shares_internal(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
&order.symbol,
|
|
signed_quantity,
|
|
order.limit_price,
|
|
&order.reason,
|
|
Some(order.order_id),
|
|
false,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cancel_open_order(
|
|
&self,
|
|
date: NaiveDate,
|
|
order_id: u64,
|
|
reason: &str,
|
|
report: &mut BrokerExecutionReport,
|
|
) {
|
|
let canceled = {
|
|
let mut open_orders = self.open_orders.borrow_mut();
|
|
if let Some(index) = open_orders
|
|
.iter()
|
|
.position(|order| order.order_id == order_id)
|
|
{
|
|
Some(open_orders.remove(index))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
if let Some(order) = canceled {
|
|
self.emit_user_canceled_open_order(date, order, reason, report);
|
|
} else {
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderCancellationReject,
|
|
order_id: Some(order_id),
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!("reason={reason} status=not_found"),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn cancel_open_orders_for_symbol(
|
|
&self,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
reason: &str,
|
|
report: &mut BrokerExecutionReport,
|
|
) {
|
|
let canceled = {
|
|
let mut open_orders = self.open_orders.borrow_mut();
|
|
let mut canceled = Vec::new();
|
|
let mut retained = Vec::with_capacity(open_orders.len());
|
|
for order in open_orders.drain(..) {
|
|
if order.symbol == symbol {
|
|
canceled.push(order);
|
|
} else {
|
|
retained.push(order);
|
|
}
|
|
}
|
|
*open_orders = retained;
|
|
canceled
|
|
};
|
|
if canceled.is_empty() {
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderCancellationReject,
|
|
order_id: None,
|
|
symbol: Some(symbol.to_string()),
|
|
side: None,
|
|
detail: format!("reason={reason} status=no_open_orders_for_symbol"),
|
|
});
|
|
}
|
|
for order in canceled {
|
|
self.emit_user_canceled_open_order(date, order, reason, report);
|
|
}
|
|
}
|
|
|
|
fn cancel_all_open_orders(
|
|
&self,
|
|
date: NaiveDate,
|
|
reason: &str,
|
|
report: &mut BrokerExecutionReport,
|
|
) {
|
|
let canceled = {
|
|
let mut open_orders = self.open_orders.borrow_mut();
|
|
std::mem::take(&mut *open_orders)
|
|
};
|
|
if canceled.is_empty() {
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind: ProcessEventKind::OrderCancellationReject,
|
|
order_id: None,
|
|
symbol: None,
|
|
side: None,
|
|
detail: format!("reason={reason} status=no_open_orders"),
|
|
});
|
|
}
|
|
for order in canceled {
|
|
self.emit_user_canceled_open_order(date, order, reason, report);
|
|
}
|
|
}
|
|
|
|
fn emit_user_canceled_open_order(
|
|
&self,
|
|
date: NaiveDate,
|
|
order: OpenOrder,
|
|
reason: &str,
|
|
report: &mut BrokerExecutionReport,
|
|
) {
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderPendingCancel,
|
|
order.order_id,
|
|
&order.symbol,
|
|
order.side,
|
|
format!("reason={reason}"),
|
|
);
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
order_id: Some(order.order_id),
|
|
symbol: order.symbol.clone(),
|
|
side: order.side,
|
|
requested_quantity: order.requested_quantity,
|
|
filled_quantity: order.filled_quantity,
|
|
status: OrderStatus::Canceled,
|
|
reason: format!("{reason}: canceled by user"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderCancellationPass,
|
|
order.order_id,
|
|
&order.symbol,
|
|
order.side,
|
|
format!(
|
|
"status=Canceled requested_quantity={} filled_quantity={}",
|
|
order.requested_quantity, order.filled_quantity
|
|
),
|
|
);
|
|
}
|
|
|
|
pub fn after_trading(&self, date: NaiveDate) -> BrokerExecutionReport {
|
|
let mut report = BrokerExecutionReport::default();
|
|
let pending = {
|
|
let mut open_orders = self.open_orders.borrow_mut();
|
|
std::mem::take(&mut *open_orders)
|
|
};
|
|
for order in pending {
|
|
let market_close_reason = format!(
|
|
"Order Rejected: {} can not match. Market close.",
|
|
order.symbol
|
|
);
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
order_id: Some(order.order_id),
|
|
symbol: order.symbol.clone(),
|
|
side: order.side,
|
|
requested_quantity: order.requested_quantity,
|
|
filled_quantity: order.filled_quantity,
|
|
status: OrderStatus::Rejected,
|
|
reason: market_close_reason.clone(),
|
|
});
|
|
Self::emit_order_process_event(
|
|
&mut report,
|
|
date,
|
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
|
order.order_id,
|
|
&order.symbol,
|
|
order.side,
|
|
format!(
|
|
"status=Rejected requested_quantity={} filled_quantity={} reason={market_close_reason}",
|
|
order.requested_quantity, order.filled_quantity
|
|
),
|
|
);
|
|
}
|
|
report
|
|
}
|
|
|
|
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 creation_reject_kind(emit_creation_events: bool) -> ProcessEventKind {
|
|
if emit_creation_events {
|
|
ProcessEventKind::OrderCreationReject
|
|
} else {
|
|
ProcessEventKind::OrderUnsolicitedUpdate
|
|
}
|
|
}
|
|
|
|
fn target_quantities(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
data: &DataSet,
|
|
target_weights: &BTreeMap<String, f64>,
|
|
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
|
|
self.target_quantities_with_valuation_prices(date, portfolio, data, target_weights, None)
|
|
}
|
|
|
|
fn target_quantities_with_valuation_prices(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
data: &DataSet,
|
|
target_weights: &BTreeMap<String, f64>,
|
|
valuation_prices: Option<&BTreeMap<String, f64>>,
|
|
) -> Result<(BTreeMap<String, u32>, Vec<String>), BacktestError> {
|
|
let equity =
|
|
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?;
|
|
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 = self.rebalance_valuation_price_with_overrides(
|
|
date,
|
|
symbol,
|
|
data,
|
|
valuation_prices,
|
|
)?;
|
|
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 = self.rebalance_valuation_price_with_overrides(
|
|
date,
|
|
&symbol,
|
|
data,
|
|
valuation_prices,
|
|
)?;
|
|
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 desired_qty < current_qty
|
|
&& min_target_qty >= current_qty
|
|
&& diagnostics.len() < 16
|
|
&& let Some(reason) = self.sell_target_denial_reason(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
&symbol,
|
|
current_qty,
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
)
|
|
{
|
|
diagnostics.push(format!(
|
|
"rebalance_target_denied symbol={} side=sell reason={}",
|
|
symbol, reason
|
|
));
|
|
}
|
|
if desired_qty > current_qty
|
|
&& max_target_qty <= current_qty
|
|
&& diagnostics.len() < 16
|
|
&& let Some(reason) = self.buy_target_denial_reason(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
&symbol,
|
|
current_qty,
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
)
|
|
{
|
|
diagnostics.push(format!(
|
|
"rebalance_target_denied symbol={} side=buy reason={}",
|
|
symbol, reason
|
|
));
|
|
}
|
|
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 process_target_portfolio_smart(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
target_weights: &BTreeMap<String, f64>,
|
|
order_prices: Option<&TargetPortfolioOrderPricing>,
|
|
valuation_prices: Option<&BTreeMap<String, f64>>,
|
|
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 (target_quantities, diagnostics) = self.target_quantities_with_valuation_prices(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
target_weights,
|
|
valuation_prices,
|
|
)?;
|
|
report.diagnostics.extend(diagnostics);
|
|
let limit_prices = match order_prices {
|
|
Some(TargetPortfolioOrderPricing::LimitPrices(prices)) => Some(prices),
|
|
_ => None,
|
|
};
|
|
let algo_request = match order_prices {
|
|
Some(TargetPortfolioOrderPricing::AlgoOrder {
|
|
style,
|
|
start_time,
|
|
end_time,
|
|
}) => Some(AlgoExecutionRequest {
|
|
style: match style {
|
|
AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap,
|
|
AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap,
|
|
},
|
|
start_time: *start_time,
|
|
end_time: *end_time,
|
|
}),
|
|
_ => None,
|
|
};
|
|
|
|
let mut symbols = BTreeSet::new();
|
|
symbols.extend(portfolio.positions().keys().cloned());
|
|
symbols.extend(target_quantities.keys().cloned());
|
|
|
|
for symbol in &symbols {
|
|
let current_qty = portfolio
|
|
.position(symbol)
|
|
.map(|pos| pos.quantity)
|
|
.unwrap_or(0);
|
|
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
|
|
if current_qty <= target_qty {
|
|
continue;
|
|
}
|
|
let sell_qty = current_qty - target_qty;
|
|
let mut local_report = BrokerExecutionReport::default();
|
|
if let Some(limit_price) =
|
|
self.required_custom_order_price(date, symbol, limit_prices)?
|
|
{
|
|
self.process_limit_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
-(sell_qty as i32),
|
|
limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
&mut local_report,
|
|
)?;
|
|
} else {
|
|
self.process_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
-(sell_qty as i32),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
algo_request.as_ref(),
|
|
&mut local_report,
|
|
)?;
|
|
}
|
|
Self::extend_report(report, local_report);
|
|
}
|
|
|
|
for symbol in &symbols {
|
|
let current_qty = portfolio
|
|
.position(symbol)
|
|
.map(|pos| pos.quantity)
|
|
.unwrap_or(0);
|
|
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
|
|
if target_qty <= current_qty {
|
|
continue;
|
|
}
|
|
let buy_qty = target_qty - current_qty;
|
|
let mut local_report = BrokerExecutionReport::default();
|
|
if let Some(limit_price) =
|
|
self.required_custom_order_price(date, symbol, limit_prices)?
|
|
{
|
|
self.process_limit_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
buy_qty as i32,
|
|
limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
&mut local_report,
|
|
)?;
|
|
} else {
|
|
self.process_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
buy_qty as i32,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
algo_request.as_ref(),
|
|
&mut local_report,
|
|
)?;
|
|
}
|
|
Self::extend_report(report, local_report);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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)
|
|
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
|
|
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 sell_target_denial_reason(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
current_qty: u32,
|
|
minimum_order_quantity: u32,
|
|
order_step_size: u32,
|
|
) -> Option<String> {
|
|
if current_qty == 0 {
|
|
return None;
|
|
}
|
|
let position = portfolio.position(symbol)?;
|
|
let snapshot = data.require_market(date, symbol).ok()?;
|
|
let candidate = data.require_candidate(date, symbol).ok()?;
|
|
let rule = self.rules.can_sell(
|
|
date,
|
|
snapshot,
|
|
candidate,
|
|
position,
|
|
self.execution_price_field,
|
|
);
|
|
if !rule.allowed {
|
|
return rule.reason;
|
|
}
|
|
let sellable = position
|
|
.sellable_qty(date)
|
|
.saturating_sub(self.reserved_open_sell_quantity(symbol, None));
|
|
match self.market_fillable_quantity(
|
|
snapshot,
|
|
OrderSide::Sell,
|
|
sellable.min(current_qty),
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
0,
|
|
sellable >= current_qty,
|
|
) {
|
|
Ok(quantity) => {
|
|
let quantity = quantity.min(sellable).min(current_qty);
|
|
if quantity == 0 {
|
|
Some("no sellable quantity".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
Err(reason) => Some(reason),
|
|
}
|
|
}
|
|
|
|
fn buy_target_denial_reason(
|
|
&self,
|
|
date: NaiveDate,
|
|
_portfolio: &PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
current_qty: u32,
|
|
minimum_order_quantity: u32,
|
|
order_step_size: u32,
|
|
) -> Option<String> {
|
|
let snapshot = data.require_market(date, symbol).ok()?;
|
|
let candidate = data.require_candidate(date, symbol).ok()?;
|
|
let rule = self
|
|
.rules
|
|
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
|
if !rule.allowed {
|
|
return rule.reason;
|
|
}
|
|
match self.market_fillable_quantity(
|
|
snapshot,
|
|
OrderSide::Buy,
|
|
u32::MAX,
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
0,
|
|
false,
|
|
) {
|
|
Ok(quantity) => {
|
|
if current_qty.saturating_add(quantity) <= current_qty {
|
|
Some("no fillable buy quantity".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
Err(reason) => Some(reason),
|
|
}
|
|
}
|
|
|
|
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>,
|
|
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)?;
|
|
let candidate = data.require_candidate(date, symbol)?;
|
|
let Some(position) = portfolio.position(symbol) else {
|
|
return Ok(());
|
|
};
|
|
|
|
if emit_creation_events {
|
|
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,
|
|
candidate,
|
|
position,
|
|
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")
|
|
| 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}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
format!("status={status:?} reason={rule_reason}"),
|
|
);
|
|
self.clear_open_order(order_id);
|
|
return Ok(());
|
|
}
|
|
|
|
if emit_creation_events {
|
|
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)
|
|
.saturating_sub(self.reserved_open_sell_quantity(symbol, Some(order_id)));
|
|
let mut partial_fill_reason = if sellable < requested_qty {
|
|
Some("sellable quantity limit".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
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 fillable_qty = match market_limited_qty {
|
|
Ok(quantity) => {
|
|
let quantity = quantity.min(sellable);
|
|
if quantity < requested_qty.min(sellable) {
|
|
partial_fill_reason = merge_partial_fill_reason(
|
|
partial_fill_reason,
|
|
Some("market liquidity or volume limit"),
|
|
);
|
|
}
|
|
quantity
|
|
}
|
|
Err(limit_reason) => {
|
|
if allow_pending_limit {
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Sell,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: 0,
|
|
remaining_quantity: requested_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit sell"),
|
|
reason: reason.to_string(),
|
|
});
|
|
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::Pending,
|
|
reason: format!("{reason}: pending due to {limit_reason}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
format!("status=Pending reason={limit_reason}"),
|
|
);
|
|
return Ok(());
|
|
}
|
|
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: zero_fill_status_for_reason(&limit_reason),
|
|
reason: format!("{reason}: {limit_reason}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
format!(
|
|
"status={:?} reason={limit_reason}",
|
|
zero_fill_status_for_reason(&limit_reason)
|
|
),
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
if fillable_qty == 0 {
|
|
if allow_pending_limit {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("no sellable quantity");
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Sell,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: 0,
|
|
remaining_quantity: requested_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit sell"),
|
|
reason: reason.to_string(),
|
|
});
|
|
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::Pending,
|
|
reason: format!("{reason}: pending due to {detail}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
format!("status=Pending reason={detail}"),
|
|
);
|
|
return Ok(());
|
|
}
|
|
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"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
"status=Rejected reason=no sellable quantity",
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let fill = self.resolve_execution_fill(
|
|
date,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
snapshot,
|
|
data,
|
|
fillable_qty,
|
|
self.round_lot(data, symbol),
|
|
self.minimum_order_quantity(data, symbol),
|
|
self.order_step_size(data, symbol),
|
|
fillable_qty >= position.quantity,
|
|
execution_cursors,
|
|
None,
|
|
None,
|
|
None,
|
|
algo_request,
|
|
limit_price,
|
|
);
|
|
let (filled_qty, execution_legs) = 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);
|
|
}
|
|
partial_fill_reason =
|
|
merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason);
|
|
(fill.quantity, fill.legs)
|
|
} else {
|
|
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell);
|
|
if let Some(reason) =
|
|
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
|
|
{
|
|
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
|
|
(0, Vec::new())
|
|
} else if !self.price_satisfies_limit(
|
|
OrderSide::Sell,
|
|
execution_price,
|
|
limit_price,
|
|
snapshot.effective_price_tick(),
|
|
) {
|
|
partial_fill_reason = merge_partial_fill_reason(
|
|
partial_fill_reason,
|
|
Some("limit price not marketable yet"),
|
|
);
|
|
(0, Vec::new())
|
|
} else {
|
|
let execution_price =
|
|
self.execution_price_with_limit_slippage(execution_price, limit_price);
|
|
(
|
|
fillable_qty,
|
|
vec![ExecutionLeg {
|
|
price: execution_price,
|
|
quantity: fillable_qty,
|
|
}],
|
|
)
|
|
}
|
|
};
|
|
if filled_qty == 0 {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("limit price not marketable yet");
|
|
if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) {
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Sell,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: 0,
|
|
remaining_quantity: requested_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit sell"),
|
|
reason: reason.to_string(),
|
|
});
|
|
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::Pending,
|
|
reason: format!("{reason}: pending due to {detail}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
format!("status=Pending reason={detail}"),
|
|
);
|
|
return Ok(());
|
|
}
|
|
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: zero_fill_status_for_reason(detail),
|
|
reason: format!("{reason}: {detail}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Sell,
|
|
format!(
|
|
"status={:?} reason={detail}",
|
|
zero_fill_status_for_reason(detail)
|
|
),
|
|
);
|
|
self.clear_open_order(order_id);
|
|
return Ok(());
|
|
}
|
|
if execution_legs.len() > 1 {
|
|
report.diagnostics.push(format!(
|
|
"order_split_fill symbol={symbol} side=sell order_id={order_id} fills={}",
|
|
execution_legs.len()
|
|
));
|
|
}
|
|
for leg in &execution_legs {
|
|
let leg_cash_before = portfolio.cash();
|
|
let gross_amount = leg.price * leg.quantity 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(leg.quantity, leg.price)
|
|
.map_err(BacktestError::Execution)?;
|
|
if let Some(position) = portfolio.position_mut_if_exists(symbol) {
|
|
position.record_trade_cost(cost.total());
|
|
}
|
|
portfolio.apply_cash_delta(net_cash);
|
|
|
|
report.fill_events.push(FillEvent {
|
|
date,
|
|
order_id: Some(order_id),
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Sell,
|
|
quantity: leg.quantity,
|
|
price: leg.price,
|
|
gross_amount,
|
|
commission: cost.commission,
|
|
stamp_tax: cost.stamp_tax,
|
|
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(),
|
|
delta_quantity: -(leg.quantity 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: leg_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}"),
|
|
});
|
|
}
|
|
portfolio.prune_flat_positions();
|
|
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
|
|
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
|
let keep_open = allow_pending_limit
|
|
&& remaining_qty > 0
|
|
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
|
|
if keep_open {
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Sell,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: filled_qty,
|
|
remaining_quantity: remaining_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit sell"),
|
|
reason: reason.to_string(),
|
|
});
|
|
} else {
|
|
self.clear_open_order(order_id);
|
|
}
|
|
|
|
let status = if keep_open {
|
|
OrderStatus::PartiallyFilled
|
|
} else if filled_qty < requested_qty {
|
|
final_partial_fill_status(partial_fill_reason.as_deref())
|
|
} else {
|
|
OrderStatus::Filled
|
|
};
|
|
let order_reason = if keep_open {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("remaining quantity could not be filled");
|
|
report.diagnostics.push(format!(
|
|
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
|
|
));
|
|
format!("{reason}: partial fill due to {detail}; remaining quantity pending")
|
|
} else if status == OrderStatus::PartiallyFilled {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("remaining quantity could not be filled");
|
|
report.diagnostics.push(format!(
|
|
"order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}"
|
|
));
|
|
format!("{reason}: partial fill due to {detail}")
|
|
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("remaining quantity could not be filled");
|
|
report.diagnostics.push(format!(
|
|
"order_remainder_canceled symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}"
|
|
));
|
|
format!("{reason}: partial fill due to {detail}; remaining quantity canceled")
|
|
} else {
|
|
reason.to_string()
|
|
};
|
|
|
|
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: 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(())
|
|
}
|
|
|
|
fn process_target_value(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
target_value: f64,
|
|
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(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
false,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
} else if target_qty > current_qty {
|
|
self.process_buy(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
target_qty - current_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
None,
|
|
false,
|
|
true,
|
|
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_target_shares(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
target_quantity: i32,
|
|
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 current_qty = portfolio
|
|
.position(symbol)
|
|
.map(|pos| pos.quantity)
|
|
.unwrap_or(0);
|
|
let target_qty = target_quantity.max(0) as u32;
|
|
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
|
|
let order_step_size = self.order_step_size(data, symbol);
|
|
|
|
if current_qty > target_qty {
|
|
let raw_sell_qty = current_qty - target_qty;
|
|
let sell_qty = if target_qty == 0 {
|
|
current_qty
|
|
} else {
|
|
self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size)
|
|
.min(current_qty)
|
|
};
|
|
if sell_qty > 0 {
|
|
self.process_sell(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
sell_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
false,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
}
|
|
} else if target_qty > current_qty {
|
|
let buy_qty = self.round_buy_quantity(
|
|
target_qty - current_qty,
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
);
|
|
if buy_qty > 0 {
|
|
self.process_buy(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
buy_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
None,
|
|
false,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
}
|
|
} else {
|
|
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 shares"),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_limit_target_value(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
target_value: f64,
|
|
limit_price: f64,
|
|
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 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(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
Some(limit_price),
|
|
true,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
} else if target_qty > current_qty {
|
|
self.process_buy(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
target_qty - current_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
Some(limit_price),
|
|
true,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_limit_target_shares(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
target_quantity: i32,
|
|
limit_price: f64,
|
|
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 current_qty = portfolio
|
|
.position(symbol)
|
|
.map(|pos| pos.quantity)
|
|
.unwrap_or(0);
|
|
let target_qty = target_quantity.max(0) as u32;
|
|
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
|
|
let order_step_size = self.order_step_size(data, symbol);
|
|
|
|
if current_qty > target_qty {
|
|
let raw_sell_qty = current_qty - target_qty;
|
|
let sell_qty = if target_qty == 0 {
|
|
current_qty
|
|
} else {
|
|
self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size)
|
|
.min(current_qty)
|
|
};
|
|
if sell_qty > 0 {
|
|
self.process_sell(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
sell_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
Some(limit_price),
|
|
true,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
}
|
|
} else if target_qty > current_qty {
|
|
let buy_qty = self.round_buy_quantity(
|
|
target_qty - current_qty,
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
);
|
|
if buy_qty > 0 {
|
|
self.process_buy(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
buy_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
Some(limit_price),
|
|
true,
|
|
true,
|
|
None,
|
|
report,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_target_percent(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
target_percent: f64,
|
|
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_target_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
total_equity * target_percent.max(0.0),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)
|
|
}
|
|
|
|
fn process_limit_target_percent(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
target_percent: f64,
|
|
limit_price: f64,
|
|
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_limit_target_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
total_equity * target_percent.max(0.0),
|
|
limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)
|
|
}
|
|
|
|
fn process_value(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
value: f64,
|
|
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.value_buy_sizing_price(date, data, symbol, snapshot);
|
|
let snapshot_requested_qty = self.value_buy_quantity(
|
|
date,
|
|
value.abs(),
|
|
price,
|
|
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,
|
|
None,
|
|
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,
|
|
None,
|
|
report,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn process_limit_value(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
value: f64,
|
|
limit_price: f64,
|
|
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.value_buy_quantity(
|
|
date,
|
|
value.abs(),
|
|
price,
|
|
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()),
|
|
Some(limit_price),
|
|
true,
|
|
true,
|
|
None,
|
|
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,
|
|
Some(limit_price),
|
|
true,
|
|
true,
|
|
None,
|
|
report,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn process_percent(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
percent: f64,
|
|
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_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
total_equity * percent,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)
|
|
}
|
|
|
|
fn process_limit_percent(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
percent: f64,
|
|
limit_price: f64,
|
|
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_limit_value(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
total_equity * percent,
|
|
limit_price,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
report,
|
|
)
|
|
}
|
|
|
|
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.value_buy_quantity(
|
|
date,
|
|
value.abs(),
|
|
price,
|
|
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,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
quantity: i32,
|
|
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>,
|
|
algo_request: Option<&AlgoExecutionRequest>,
|
|
report: &mut BrokerExecutionReport,
|
|
) -> Result<(), BacktestError> {
|
|
if quantity == 0 {
|
|
return Ok(());
|
|
}
|
|
if quantity > 0 {
|
|
let requested_qty = self.round_buy_quantity(
|
|
quantity as u32,
|
|
self.minimum_order_quantity(data, symbol),
|
|
self.order_step_size(data, symbol),
|
|
);
|
|
self.process_buy(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
requested_qty,
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
None,
|
|
false,
|
|
true,
|
|
algo_request,
|
|
report,
|
|
)
|
|
} else {
|
|
self.process_sell(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
quantity.unsigned_abs(),
|
|
self.reserve_order_id(),
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
false,
|
|
true,
|
|
algo_request,
|
|
report,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn process_lots(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &mut PortfolioState,
|
|
data: &DataSet,
|
|
symbol: &str,
|
|
lots: i32,
|
|
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 round_lot = self.round_lot(data, symbol);
|
|
let requested_quantity = lots.saturating_abs() as u32 * round_lot;
|
|
let signed_quantity = if lots >= 0 {
|
|
requested_quantity as i32
|
|
} else {
|
|
-(requested_quantity as i32)
|
|
};
|
|
self.process_shares(
|
|
date,
|
|
portfolio,
|
|
data,
|
|
symbol,
|
|
signed_quantity,
|
|
reason,
|
|
intraday_turnover,
|
|
execution_cursors,
|
|
global_execution_cursor,
|
|
commission_state,
|
|
None,
|
|
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 value_buy_gross_limit(
|
|
&self,
|
|
value_budget: Option<f64>,
|
|
requested_qty: u32,
|
|
reference_price: f64,
|
|
) -> Option<f64> {
|
|
if !self.strict_value_budget {
|
|
return None;
|
|
}
|
|
value_budget.map(|budget| budget.max(reference_price * requested_qty as f64))
|
|
}
|
|
|
|
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>,
|
|
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)?;
|
|
let candidate = data.require_candidate(date, symbol)?;
|
|
|
|
if emit_creation_events {
|
|
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")
|
|
| Some("open at or above upper limit") => OrderStatus::Canceled,
|
|
_ => OrderStatus::Rejected,
|
|
};
|
|
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,
|
|
reason: format!("{reason}: {rule_reason}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Buy,
|
|
format!("status={status:?} reason={rule_reason}"),
|
|
);
|
|
self.clear_open_order(order_id);
|
|
return Ok(());
|
|
}
|
|
|
|
if emit_creation_events {
|
|
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,
|
|
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) => {
|
|
if quantity < requested_qty {
|
|
partial_fill_reason = Some("market liquidity or volume limit".to_string());
|
|
}
|
|
quantity
|
|
}
|
|
Err(limit_reason) => {
|
|
if allow_pending_limit {
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Buy,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: 0,
|
|
remaining_quantity: requested_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit buy"),
|
|
reason: reason.to_string(),
|
|
});
|
|
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::Pending,
|
|
reason: format!("{reason}: pending due to {limit_reason}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Buy,
|
|
format!("status=Pending reason={limit_reason}"),
|
|
);
|
|
return Ok(());
|
|
}
|
|
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: zero_fill_status_for_reason(&limit_reason),
|
|
reason: format!("{reason}: {limit_reason}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Buy,
|
|
format!(
|
|
"status={:?} reason={limit_reason}",
|
|
zero_fill_status_for_reason(&limit_reason)
|
|
),
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
let value_gross_limit =
|
|
self.value_buy_gross_limit(value_budget, constrained_qty, self.sizing_price(snapshot));
|
|
|
|
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_gross_limit,
|
|
algo_request,
|
|
limit_price,
|
|
);
|
|
let (filled_qty, execution_legs) = 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);
|
|
}
|
|
partial_fill_reason =
|
|
merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason);
|
|
(fill.quantity, fill.legs)
|
|
} else {
|
|
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
|
|
if let Some(reason) =
|
|
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
|
|
{
|
|
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
|
|
(0, Vec::new())
|
|
} else if !self.price_satisfies_limit(
|
|
OrderSide::Buy,
|
|
execution_price,
|
|
limit_price,
|
|
snapshot.effective_price_tick(),
|
|
) {
|
|
partial_fill_reason = merge_partial_fill_reason(
|
|
partial_fill_reason,
|
|
Some("limit price not marketable yet"),
|
|
);
|
|
(0, Vec::new())
|
|
} else {
|
|
let execution_price =
|
|
self.execution_price_with_limit_slippage(execution_price, limit_price);
|
|
let filled_qty = self.affordable_buy_quantity(
|
|
date,
|
|
portfolio.cash(),
|
|
value_gross_limit,
|
|
execution_price,
|
|
constrained_qty,
|
|
self.minimum_order_quantity(data, symbol),
|
|
self.order_step_size(data, symbol),
|
|
);
|
|
if filled_qty < constrained_qty {
|
|
partial_fill_reason = merge_partial_fill_reason(
|
|
partial_fill_reason,
|
|
self.buy_reduction_reason(
|
|
portfolio.cash(),
|
|
value_gross_limit,
|
|
execution_price,
|
|
constrained_qty,
|
|
filled_qty,
|
|
),
|
|
);
|
|
}
|
|
(
|
|
filled_qty,
|
|
vec![ExecutionLeg {
|
|
price: execution_price,
|
|
quantity: filled_qty,
|
|
}],
|
|
)
|
|
}
|
|
};
|
|
if filled_qty == 0 {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("insufficient cash after fees");
|
|
if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) {
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Buy,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: 0,
|
|
remaining_quantity: requested_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit buy"),
|
|
reason: reason.to_string(),
|
|
});
|
|
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::Pending,
|
|
reason: format!("{reason}: pending due to {detail}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Buy,
|
|
format!("status=Pending reason={detail}"),
|
|
);
|
|
return Ok(());
|
|
}
|
|
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: zero_fill_status_for_reason(detail),
|
|
reason: format!("{reason}: {detail}"),
|
|
});
|
|
Self::emit_order_process_event(
|
|
report,
|
|
date,
|
|
Self::creation_reject_kind(emit_creation_events),
|
|
order_id,
|
|
symbol,
|
|
OrderSide::Buy,
|
|
format!(
|
|
"status={:?} reason={detail}",
|
|
zero_fill_status_for_reason(detail)
|
|
),
|
|
);
|
|
self.clear_open_order(order_id);
|
|
return Ok(());
|
|
}
|
|
|
|
if execution_legs.len() > 1 {
|
|
report.diagnostics.push(format!(
|
|
"order_split_fill symbol={symbol} side=buy order_id={order_id} fills={}",
|
|
execution_legs.len()
|
|
));
|
|
}
|
|
for leg in &execution_legs {
|
|
let leg_cash_before = portfolio.cash();
|
|
let gross_amount = leg.price * leg.quantity 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, leg.quantity, leg.price);
|
|
if let Some(position) = portfolio.position_mut_if_exists(symbol) {
|
|
position.record_buy_trade_cost(leg.quantity, cost.total());
|
|
}
|
|
|
|
report.fill_events.push(FillEvent {
|
|
date,
|
|
order_id: Some(order_id),
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Buy,
|
|
quantity: leg.quantity,
|
|
price: leg.price,
|
|
gross_amount,
|
|
commission: cost.commission,
|
|
stamp_tax: cost.stamp_tax,
|
|
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(),
|
|
delta_quantity: leg.quantity 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: leg_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}"),
|
|
});
|
|
}
|
|
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
|
|
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
|
let keep_open = allow_pending_limit
|
|
&& remaining_qty > 0
|
|
&& Self::limit_order_can_remain_open(partial_fill_reason.as_deref());
|
|
if keep_open {
|
|
self.upsert_open_order(OpenOrder {
|
|
order_id,
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Buy,
|
|
requested_quantity: requested_qty,
|
|
filled_quantity: filled_qty,
|
|
remaining_quantity: remaining_qty,
|
|
limit_price: limit_price.expect("limit price for pending limit buy"),
|
|
reason: reason.to_string(),
|
|
});
|
|
} else {
|
|
self.clear_open_order(order_id);
|
|
}
|
|
|
|
let status = if keep_open {
|
|
OrderStatus::PartiallyFilled
|
|
} else if filled_qty < requested_qty {
|
|
final_partial_fill_status(partial_fill_reason.as_deref())
|
|
} else {
|
|
OrderStatus::Filled
|
|
};
|
|
let order_reason = if keep_open {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("remaining quantity could not be filled");
|
|
report.diagnostics.push(format!(
|
|
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}; remaining open"
|
|
));
|
|
format!("{reason}: partial fill due to {detail}; remaining quantity pending")
|
|
} else if status == OrderStatus::PartiallyFilled {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("remaining quantity could not be filled");
|
|
report.diagnostics.push(format!(
|
|
"order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}"
|
|
));
|
|
format!("{reason}: partial fill due to {detail}")
|
|
} else if status == OrderStatus::Canceled && filled_qty < requested_qty {
|
|
let detail = partial_fill_reason
|
|
.as_deref()
|
|
.unwrap_or("remaining quantity could not be filled");
|
|
report.diagnostics.push(format!(
|
|
"order_remainder_canceled symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}"
|
|
));
|
|
format!("{reason}: partial fill due to {detail}; remaining quantity canceled")
|
|
} else {
|
|
reason.to_string()
|
|
};
|
|
|
|
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: 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(())
|
|
}
|
|
|
|
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)
|
|
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
|
|
.or_else(|| {
|
|
(position.last_price.is_finite() && position.last_price > 0.0)
|
|
.then_some(position.last_price)
|
|
})
|
|
.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 rebalance_valuation_price_field_name(&self) -> &'static str {
|
|
if self.is_open_auction_matching() {
|
|
"prev_close"
|
|
} else {
|
|
price_field_name(self.execution_price_field)
|
|
}
|
|
}
|
|
|
|
fn rebalance_valuation_price_for_snapshot(
|
|
&self,
|
|
snapshot: &crate::data::DailyMarketSnapshot,
|
|
) -> Option<f64> {
|
|
let price = if self.is_open_auction_matching() {
|
|
snapshot.prev_close
|
|
} else {
|
|
snapshot.price(self.execution_price_field)
|
|
};
|
|
if price.is_finite() && price > 0.0 {
|
|
Some(price)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn rebalance_valuation_price_with_overrides(
|
|
&self,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
data: &DataSet,
|
|
valuation_prices: Option<&BTreeMap<String, f64>>,
|
|
) -> Result<f64, BacktestError> {
|
|
if let Some(prices) = valuation_prices {
|
|
if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) {
|
|
return Ok(price);
|
|
}
|
|
return Err(BacktestError::MissingPrice {
|
|
date,
|
|
symbol: symbol.to_string(),
|
|
field: "custom valuation",
|
|
});
|
|
}
|
|
let snapshot = data
|
|
.market(date, symbol)
|
|
.ok_or_else(|| BacktestError::MissingPrice {
|
|
date,
|
|
symbol: symbol.to_string(),
|
|
field: self.rebalance_valuation_price_field_name(),
|
|
})?;
|
|
self.rebalance_valuation_price_for_snapshot(snapshot)
|
|
.ok_or_else(|| BacktestError::MissingPrice {
|
|
date,
|
|
symbol: symbol.to_string(),
|
|
field: self.rebalance_valuation_price_field_name(),
|
|
})
|
|
}
|
|
|
|
fn rebalance_total_equity_at(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
data: &DataSet,
|
|
) -> Result<f64, BacktestError> {
|
|
self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None)
|
|
}
|
|
|
|
fn rebalance_total_equity_at_with_overrides(
|
|
&self,
|
|
date: NaiveDate,
|
|
portfolio: &PortfolioState,
|
|
data: &DataSet,
|
|
valuation_prices: Option<&BTreeMap<String, f64>>,
|
|
) -> Result<f64, BacktestError> {
|
|
let mut market_value = 0.0;
|
|
for position in portfolio.positions().values() {
|
|
let price = if valuation_prices.is_some() {
|
|
self.rebalance_valuation_price_with_overrides(
|
|
date,
|
|
&position.symbol,
|
|
data,
|
|
valuation_prices,
|
|
)?
|
|
} else if let Some(snapshot) = data.market(date, &position.symbol) {
|
|
self.rebalance_valuation_price_for_snapshot(snapshot)
|
|
.ok_or_else(|| BacktestError::MissingPrice {
|
|
date,
|
|
symbol: position.symbol.clone(),
|
|
field: self.rebalance_valuation_price_field_name(),
|
|
})?
|
|
} else {
|
|
data.price_on_or_before(date, &position.symbol, PriceField::Close)
|
|
.or_else(|| {
|
|
(position.last_price.is_finite() && position.last_price > 0.0)
|
|
.then_some(position.last_price)
|
|
})
|
|
.ok_or_else(|| BacktestError::MissingPrice {
|
|
date,
|
|
symbol: position.symbol.clone(),
|
|
field: self.rebalance_valuation_price_field_name(),
|
|
})?
|
|
};
|
|
market_value += price * position.quantity as f64;
|
|
}
|
|
Ok(portfolio.cash() + market_value)
|
|
}
|
|
|
|
fn required_custom_order_price(
|
|
&self,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
order_prices: Option<&BTreeMap<String, f64>>,
|
|
) -> Result<Option<f64>, BacktestError> {
|
|
let Some(prices) = order_prices else {
|
|
return Ok(None);
|
|
};
|
|
if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) {
|
|
Ok(Some(price))
|
|
} else {
|
|
Err(BacktestError::MissingPrice {
|
|
date,
|
|
symbol: symbol.to_string(),
|
|
field: "custom order",
|
|
})
|
|
}
|
|
}
|
|
|
|
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 value_buy_quantity(
|
|
&self,
|
|
date: NaiveDate,
|
|
value_budget: f64,
|
|
price: f64,
|
|
minimum_order_quantity: u32,
|
|
order_step_size: u32,
|
|
) -> u32 {
|
|
if !value_budget.is_finite() || value_budget <= 0.0 || !price.is_finite() || price <= 0.0 {
|
|
return 0;
|
|
}
|
|
let minimum = minimum_order_quantity.max(1);
|
|
let raw_quantity = (value_budget / price).floor() as u32;
|
|
let mut quantity =
|
|
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
|
|
while quantity >= minimum {
|
|
if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 {
|
|
return quantity;
|
|
}
|
|
quantity =
|
|
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
|
|
}
|
|
0
|
|
}
|
|
|
|
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 buy_reduction_reason(
|
|
&self,
|
|
cash_limit: f64,
|
|
gross_limit: Option<f64>,
|
|
price: f64,
|
|
requested_qty: u32,
|
|
filled_qty: u32,
|
|
) -> Option<&'static str> {
|
|
if filled_qty >= requested_qty {
|
|
return None;
|
|
}
|
|
if gross_limit.is_some_and(|limit| price * requested_qty as f64 > limit + 1e-6) {
|
|
Some("value budget limit")
|
|
} else if cash_limit.is_finite() {
|
|
Some("insufficient cash after fees")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
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 price_satisfies_limit(
|
|
&self,
|
|
side: OrderSide,
|
|
execution_price: f64,
|
|
limit_price: Option<f64>,
|
|
price_tick: f64,
|
|
) -> bool {
|
|
let Some(limit_price) = limit_price else {
|
|
return execution_price.is_finite() && execution_price > 0.0;
|
|
};
|
|
if !execution_price.is_finite() || execution_price <= 0.0 {
|
|
return false;
|
|
}
|
|
let tolerance = price_tick.abs().max(1e-9);
|
|
match side {
|
|
OrderSide::Buy => execution_price <= limit_price + tolerance,
|
|
OrderSide::Sell => execution_price + tolerance >= limit_price,
|
|
}
|
|
}
|
|
|
|
fn execution_limit_rejection_reason(
|
|
&self,
|
|
snapshot: &crate::data::DailyMarketSnapshot,
|
|
side: OrderSide,
|
|
execution_price: f64,
|
|
) -> Option<&'static str> {
|
|
if !execution_price.is_finite() || execution_price <= 0.0 {
|
|
return None;
|
|
}
|
|
match side {
|
|
OrderSide::Buy if snapshot.is_at_upper_limit_price(execution_price) => {
|
|
Some("open at or above upper limit")
|
|
}
|
|
OrderSide::Sell if snapshot.is_at_lower_limit_price(execution_price) => {
|
|
Some("open at or below lower limit")
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn execution_price_with_limit_slippage(
|
|
&self,
|
|
execution_price: f64,
|
|
limit_price: Option<f64>,
|
|
) -> f64 {
|
|
match (self.slippage_model, limit_price) {
|
|
(SlippageModel::LimitPrice, Some(limit_price)) => limit_price,
|
|
_ => execution_price,
|
|
}
|
|
}
|
|
|
|
fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool {
|
|
!partial_reason.is_some_and(|reason| {
|
|
reason.contains("insufficient cash")
|
|
|| reason.contains("value budget")
|
|
|| reason.contains("open at or above upper limit")
|
|
|| reason.contains("open at or below lower limit")
|
|
})
|
|
}
|
|
|
|
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>,
|
|
algo_request: Option<&AlgoExecutionRequest>,
|
|
limit_price: Option<f64>,
|
|
) -> Option<ExecutionFill> {
|
|
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 runtime_start_time = self.runtime_intraday_start_time.get();
|
|
let runtime_end_time = self.runtime_intraday_end_time.get();
|
|
let start_cursor = algo_request
|
|
.and_then(|request| request.start_time)
|
|
.or(runtime_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)
|
|
.or(runtime_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,
|
|
order_step_size,
|
|
allow_odd_lot_sell,
|
|
cash_limit,
|
|
gross_limit,
|
|
limit_price,
|
|
) {
|
|
return Some(fill);
|
|
}
|
|
|
|
if algo_request.is_some() || self.intraday_execution_start_time.is_some() {
|
|
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 {
|
|
quantity: 0,
|
|
next_cursor,
|
|
legs: Vec::new(),
|
|
unfilled_reason: Some(self.empty_intraday_quote_reason(
|
|
quotes,
|
|
start_cursor,
|
|
end_cursor,
|
|
)),
|
|
});
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn empty_intraday_quote_reason(
|
|
&self,
|
|
quotes: &[IntradayExecutionQuote],
|
|
start_cursor: Option<NaiveDateTime>,
|
|
end_cursor: Option<NaiveDateTime>,
|
|
) -> &'static str {
|
|
let saw_quote_in_window = quotes.iter().any(|quote| {
|
|
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
|
|
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
|
|
});
|
|
if saw_quote_in_window {
|
|
"intraday quote liquidity exhausted"
|
|
} else {
|
|
"no execution quotes after start"
|
|
}
|
|
}
|
|
|
|
fn select_execution_fill(
|
|
&self,
|
|
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,
|
|
order_step_size: u32,
|
|
allow_odd_lot_sell: bool,
|
|
cash_limit: Option<f64>,
|
|
gross_limit: Option<f64>,
|
|
limit_price: Option<f64>,
|
|
) -> Option<ExecutionFill> {
|
|
if requested_qty == 0 {
|
|
return None;
|
|
}
|
|
|
|
let quote_quantity_limited =
|
|
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
|
|
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_quantity_limited
|
|
|| self.quote_has_executable_liquidity(quote, side, matching_type))
|
|
})
|
|
.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 execution_block_reason = None;
|
|
let mut execution_block_timestamp = None;
|
|
let mut saw_non_blocked_execution_price = false;
|
|
let saw_quote_after_cursor = !eligible_quotes.is_empty();
|
|
|
|
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
|
// Approximate platform-native 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.
|
|
let Some(quote_price) =
|
|
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
|
{
|
|
execution_block_reason.get_or_insert(reason);
|
|
execution_block_timestamp = Some(quote.timestamp);
|
|
continue;
|
|
}
|
|
saw_non_blocked_execution_price = true;
|
|
if !self.price_satisfies_limit(
|
|
side,
|
|
quote_price,
|
|
limit_price,
|
|
snapshot.effective_price_tick(),
|
|
) {
|
|
continue;
|
|
}
|
|
let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
|
|
|
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
|
if remaining_qty == 0 {
|
|
break;
|
|
}
|
|
let available_qty = if quote_quantity_limited {
|
|
let top_level_liquidity = match side {
|
|
OrderSide::Buy => quote.ask1_volume,
|
|
OrderSide::Sell => quote.bid1_volume,
|
|
};
|
|
top_level_liquidity
|
|
.saturating_mul(lot as u64)
|
|
.min(u32::MAX as u64) as u32
|
|
} else {
|
|
remaining_qty
|
|
};
|
|
if available_qty == 0 {
|
|
continue;
|
|
}
|
|
|
|
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);
|
|
}
|
|
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) {
|
|
budget_block_reason = Some("value budget limit");
|
|
take_qty = self.decrement_order_quantity(
|
|
take_qty,
|
|
minimum_order_quantity,
|
|
order_step_size,
|
|
);
|
|
continue;
|
|
}
|
|
if candidate_gross <= cash + 1e-6 {
|
|
break;
|
|
}
|
|
budget_block_reason = Some("insufficient cash after fees");
|
|
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);
|
|
legs.push(ExecutionLeg {
|
|
price: quote_price,
|
|
quantity: take_qty,
|
|
});
|
|
|
|
if filled_qty >= requested_qty {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if filled_qty == 0 {
|
|
if let Some(reason) = execution_block_reason
|
|
&& !saw_non_blocked_execution_price
|
|
{
|
|
return Some(ExecutionFill {
|
|
quantity: 0,
|
|
next_cursor: execution_block_timestamp
|
|
.expect("blocked execution quote timestamp")
|
|
+ Duration::seconds(1),
|
|
legs: Vec::new(),
|
|
unfilled_reason: Some(reason),
|
|
});
|
|
}
|
|
return None;
|
|
}
|
|
|
|
Some(ExecutionFill {
|
|
quantity: filled_qty,
|
|
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
|
|
legs: if matching_type == MatchingType::Vwap {
|
|
vec![ExecutionLeg {
|
|
price: gross_amount / filled_qty as f64,
|
|
quantity: filled_qty,
|
|
}]
|
|
} else {
|
|
legs
|
|
},
|
|
unfilled_reason: if filled_qty < requested_qty {
|
|
budget_block_reason.or(if saw_quote_after_cursor {
|
|
Some("intraday quote liquidity exhausted")
|
|
} else {
|
|
Some("no execution quotes after start")
|
|
})
|
|
} else {
|
|
None
|
|
},
|
|
})
|
|
}
|
|
|
|
fn quote_has_executable_liquidity(
|
|
&self,
|
|
quote: &IntradayExecutionQuote,
|
|
side: OrderSide,
|
|
matching_type: MatchingType,
|
|
) -> bool {
|
|
if quote.volume_delta != 0 {
|
|
return true;
|
|
}
|
|
if matches!(matching_type, MatchingType::Vwap | MatchingType::Twap) {
|
|
return false;
|
|
}
|
|
match side {
|
|
OrderSide::Buy => quote.ask1_volume > 0,
|
|
OrderSide::Sell => quote.bid1_volume > 0,
|
|
}
|
|
}
|
|
|
|
fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool {
|
|
self.volume_limit || self.liquidity_limit || matching_type != MatchingType::NextTickLast
|
|
}
|
|
|
|
fn quote_quantity_limited_for_window(
|
|
&self,
|
|
matching_type: MatchingType,
|
|
start_cursor: Option<NaiveDateTime>,
|
|
end_cursor: Option<NaiveDateTime>,
|
|
) -> bool {
|
|
if matching_type == MatchingType::Twap
|
|
&& !self.volume_limit
|
|
&& !self.liquidity_limit
|
|
&& start_cursor.is_some()
|
|
&& start_cursor == end_cursor
|
|
{
|
|
return false;
|
|
}
|
|
self.quote_quantity_limited(matching_type)
|
|
}
|
|
|
|
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
|
let _ = reason;
|
|
false
|
|
}
|
|
}
|
|
|
|
fn matching_type_from_price_field(field: PriceField) -> MatchingType {
|
|
match field {
|
|
PriceField::DayOpen => MatchingType::OpenAuction,
|
|
PriceField::Open => MatchingType::NextBarOpen,
|
|
PriceField::Close => MatchingType::CurrentBarClose,
|
|
PriceField::Last => MatchingType::NextTickLast,
|
|
}
|
|
}
|
|
|
|
fn merge_partial_fill_reason(current: Option<String>, next: Option<&str>) -> Option<String> {
|
|
match (current, next) {
|
|
(Some(existing), Some(next_reason)) if !existing.contains(next_reason) => {
|
|
Some(format!("{existing}; {next_reason}"))
|
|
}
|
|
(Some(existing), _) => Some(existing),
|
|
(None, Some(next_reason)) => Some(next_reason.to_string()),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
|
|
match reason {
|
|
"tick no volume"
|
|
| "tick volume limit"
|
|
| "intraday quote liquidity exhausted"
|
|
| "no execution quotes after start"
|
|
| "open at or above upper limit"
|
|
| "open at or below lower limit" => OrderStatus::Canceled,
|
|
_ => OrderStatus::Rejected,
|
|
}
|
|
}
|
|
|
|
fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
|
|
match partial_reason {
|
|
Some(reason)
|
|
if reason.contains("market liquidity or volume limit")
|
|
|| reason.contains("intraday quote liquidity exhausted")
|
|
|| reason.contains("no execution quotes after start")
|
|
|| reason.contains("open at or above upper limit")
|
|
|| reason.contains("open at or below lower limit") =>
|
|
{
|
|
OrderStatus::Canceled
|
|
}
|
|
_ => OrderStatus::PartiallyFilled,
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{BrokerSimulator, MatchingType};
|
|
use crate::cost::ChinaAShareCostModel;
|
|
use crate::data::{DailyMarketSnapshot, IntradayExecutionQuote, PriceField};
|
|
use crate::events::OrderSide;
|
|
use crate::rules::ChinaEquityRuleHooks;
|
|
|
|
fn limit_test_snapshot() -> DailyMarketSnapshot {
|
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
|
DailyMarketSnapshot {
|
|
date,
|
|
symbol: "000001.SZ".to_string(),
|
|
timestamp: Some("2025-01-02 09:33:00".to_string()),
|
|
day_open: 10.0,
|
|
open: 10.0,
|
|
high: 10.5,
|
|
low: 9.5,
|
|
close: 10.0,
|
|
last_price: 10.0,
|
|
bid1: 10.0,
|
|
ask1: 10.0,
|
|
prev_close: 10.0,
|
|
volume: 1_000_000,
|
|
tick_volume: 10_000,
|
|
bid1_volume: 1_000,
|
|
ask1_volume: 1_000,
|
|
trading_phase: Some("continuous".to_string()),
|
|
paused: false,
|
|
upper_limit: 11.0,
|
|
lower_limit: 9.0,
|
|
price_tick: 0.01,
|
|
}
|
|
}
|
|
|
|
fn limit_test_quote(last_price: f64, bid1: f64, ask1: f64) -> IntradayExecutionQuote {
|
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
|
IntradayExecutionQuote {
|
|
date,
|
|
symbol: "000001.SZ".to_string(),
|
|
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
|
last_price,
|
|
bid1,
|
|
ask1,
|
|
bid1_volume: 1_000,
|
|
ask1_volume: 1_000,
|
|
volume_delta: 1_000,
|
|
amount_delta: last_price * 1_000.0,
|
|
trading_phase: Some("continuous".to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
|
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false);
|
|
|
|
assert!(!broker.quote_quantity_limited(MatchingType::NextTickLast));
|
|
assert!(broker.quote_quantity_limited(MatchingType::CounterpartyOffer));
|
|
}
|
|
|
|
#[test]
|
|
fn next_tick_last_keeps_quote_quantity_cap_when_limits_enabled() {
|
|
let volume_limited =
|
|
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
|
.with_volume_limit(true)
|
|
.with_liquidity_limit(false);
|
|
let liquidity_limited =
|
|
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(true);
|
|
|
|
assert!(volume_limited.quote_quantity_limited(MatchingType::NextTickLast));
|
|
assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextTickLast));
|
|
}
|
|
|
|
#[test]
|
|
fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() {
|
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false);
|
|
let cursor = chrono::NaiveDate::from_ymd_opt(2025, 11, 3)
|
|
.unwrap()
|
|
.and_hms_opt(9, 31, 0)
|
|
.unwrap();
|
|
|
|
assert!(!broker.quote_quantity_limited_for_window(
|
|
MatchingType::Twap,
|
|
Some(cursor),
|
|
Some(cursor)
|
|
));
|
|
assert!(broker.quote_quantity_limited_for_window(
|
|
MatchingType::Twap,
|
|
Some(cursor),
|
|
Some(cursor + chrono::Duration::minutes(1))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn intraday_execution_rejects_buy_at_upper_limit_price() {
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Last,
|
|
)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false);
|
|
let snapshot = limit_test_snapshot();
|
|
let quote = limit_test_quote(11.0, 10.99, 11.0);
|
|
let start = quote.timestamp;
|
|
|
|
let fill = broker
|
|
.select_execution_fill(
|
|
&snapshot,
|
|
&[quote],
|
|
OrderSide::Buy,
|
|
MatchingType::NextTickLast,
|
|
Some(start),
|
|
None,
|
|
100,
|
|
100,
|
|
100,
|
|
100,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("zero fill with rejection reason");
|
|
|
|
assert_eq!(fill.quantity, 0);
|
|
assert_eq!(fill.unfilled_reason, Some("open at or above upper limit"));
|
|
}
|
|
|
|
#[test]
|
|
fn intraday_execution_rejects_sell_at_lower_limit_price() {
|
|
let broker = BrokerSimulator::new_with_execution_price(
|
|
ChinaAShareCostModel::default(),
|
|
ChinaEquityRuleHooks,
|
|
PriceField::Last,
|
|
)
|
|
.with_volume_limit(false)
|
|
.with_liquidity_limit(false)
|
|
.with_inactive_limit(false);
|
|
let snapshot = limit_test_snapshot();
|
|
let quote = limit_test_quote(9.0, 9.0, 9.01);
|
|
let start = quote.timestamp;
|
|
|
|
let fill = broker
|
|
.select_execution_fill(
|
|
&snapshot,
|
|
&[quote],
|
|
OrderSide::Sell,
|
|
MatchingType::NextTickLast,
|
|
Some(start),
|
|
None,
|
|
100,
|
|
100,
|
|
100,
|
|
100,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("zero fill with rejection reason");
|
|
|
|
assert_eq!(fill.quantity, 0);
|
|
assert_eq!(fill.unfilled_reason, Some("open at or below lower limit"));
|
|
}
|
|
}
|