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